Compare commits

...

15 Commits

Author SHA1 Message Date
Jim Liu 宝玉 12e207dc3f chore: release v1.80.0 2026-03-24 19:27:57 -05:00
Jim Liu 宝玉 00e74ab071 feat(baoyu-image-gen): improve Azure OpenAI provider with flexible endpoint parsing and deployment resolution 2026-03-24 19:19:49 -05:00
优弧 1653b8544b feat(baoyu-image-gen): add Azure OpenAI as independent image generation provider (#111)
Azure OpenAI differs from standard OpenAI in two ways:
1. Auth via api-key header instead of Authorization: Bearer
2. URL requires ?api-version query param with deployment path

Changes:
- New file: scripts/providers/azure.ts (generations + edits, reuses openai utilities)
- types.ts: add "azure" to Provider type and default_model
- main.ts: register azure across rate limits, CLI args, auto-detection,
  provider loading, model resolution, help text, ref validation,
  EXTEND.md parsing, and batch logging

Env vars: AZURE_OPENAI_API_KEY, AZURE_OPENAI_BASE_URL (required),
AZURE_API_VERSION, AZURE_OPENAI_IMAGE_MODEL (optional)

Co-authored-by: CatFly <zw.catfly@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:04:34 -05:00
Jim Liu 宝玉 dad8f3a800 chore: release v1.79.2 2026-03-23 22:39:14 -05:00
Jim Liu 宝玉 35298d7c9d fix(baoyu-post-to-weibo): add no-theme rule for article markdown-to-HTML conversion 2026-03-23 22:38:30 -05:00
Jim Liu 宝玉 f22374ab62 fix(baoyu-cover-image): simplify reference image handling based on model capability 2026-03-23 22:38:27 -05:00
Jim Liu 宝玉 d4e80b1bc3 Fix Node-compatible parser tests (#107)
* Fix Node-compatible parser tests

* Add parser test dependencies to root test env
2026-03-23 15:30:42 -05:00
Jim Liu 宝玉 a5761dc71a chore: release v1.79.1 2026-03-23 12:02:44 -05:00
Jim Liu 宝玉 a5189dff37 fix(baoyu-xhs-images): remove opacity from watermark prompt and fix CJK spacing 2026-03-23 12:01:03 -05:00
Jim Liu 宝玉 39fe872bf3 fix(baoyu-comic): fix Doraemon naming spacing and remove opacity from watermark prompt 2026-03-23 12:01:00 -05:00
Jim Liu 宝玉 52813504f8 fix(baoyu-article-illustrator): remove opacity parameter from watermark prompt 2026-03-23 12:00:53 -05:00
Jim Liu 宝玉 a4d4108cd1 docs(project): update documentation to reflect single-plugin architecture 2026-03-23 12:00:38 -05:00
Yizhou Qian 钱亦舟 d7e763f1f5 fix: consolidate to single plugin to prevent duplicate skill registration (#106)
Merge the three plugins (content-skills, ai-generation-skills,
utility-skills) into one plugin entry. Since all three shared the same
source ("./"), Claude Code cached every skill three times. A single
plugin with one source keeps the flat skills/ layout while ensuring
each skill is registered exactly once.
2026-03-23 09:46:50 -05:00
Jim Liu 宝玉 097c09c59b chore: release v1.79.0 2026-03-22 15:42:33 -05:00
Jim Liu 宝玉 e4cd8bfefc feat(baoyu-post-to-wechat): improve credential loading with multi-source resolution and diagnostics 2026-03-22 15:42:08 -05:00
27 changed files with 1072 additions and 210 deletions
+14 -30
View File
@@ -6,48 +6,32 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.78.0" "version": "1.80.0"
}, },
"plugins": [ "plugins": [
{ {
"name": "content-skills", "name": "baoyu-skills",
"description": "Content generation and publishing skills", "description": "Content generation, AI backends, and utility tools for daily work efficiency",
"source": "./", "source": "./",
"strict": true, "strict": true,
"skills": [ "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-article-illustrator",
"./skills/baoyu-cover-image",
"./skills/baoyu-slide-deck",
"./skills/baoyu-comic", "./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-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-format-markdown",
"./skills/baoyu-image-gen",
"./skills/baoyu-infographic",
"./skills/baoyu-markdown-to-html", "./skills/baoyu-markdown-to-html",
"./skills/baoyu-post-to-weibo",
"./skills/baoyu-post-to-wechat",
"./skills/baoyu-post-to-x",
"./skills/baoyu-slide-deck",
"./skills/baoyu-translate", "./skills/baoyu-translate",
"./skills/baoyu-url-to-markdown",
"./skills/baoyu-xhs-images",
"./skills/baoyu-youtube-transcript" "./skills/baoyu-youtube-transcript"
] ]
} }
+30
View File
@@ -2,6 +2,36 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 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 ## 1.78.0 - 2026-03-22
### Features ### Features
+30
View File
@@ -2,6 +2,36 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 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 ## 1.78.0 - 2026-03-22
### 新功能 ### 新功能
+8 -8
View File
@@ -1,16 +1,16 @@
# CLAUDE.md # CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.78.0**. Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.80.0**.
## Architecture ## 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 | | Group | Description |
|----------|-------------| |-------|-------------|
| `content-skills` | Generate or publish content (images, slides, comics, posts) | | Content Skills | Generate or publish content (images, slides, comics, posts) |
| `ai-generation-skills` | AI generation backends | | AI Generation Skills | AI generation backends |
| `utility-skills` | Content processing (conversion, compression, translation) | | Utility Skills | Content processing (conversion, compression, translation) |
Each skill contains `SKILL.md` (YAML front matter + docs), optional `scripts/`, `references/`, `prompts/`. 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`) - **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) - **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-image-gen` 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) - **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh)
## Security ## Security
+14 -13
View File
@@ -52,16 +52,14 @@ Run the following command in Claude Code:
1. Select **Browse and install plugins** 1. Select **Browse and install plugins**
2. Select **baoyu-skills** 2. Select **baoyu-skills**
3. Select the plugin(s) you want to install 3. Select the **baoyu-skills** plugin
4. Select **Install now** 4. Select **Install now**
**Option 2: Direct Install** **Option 2: Direct Install**
```bash ```bash
# Install specific plugin # Install the marketplace's single plugin
/plugin install content-skills@baoyu-skills /plugin install baoyu-skills@baoyu-skills
/plugin install ai-generation-skills@baoyu-skills
/plugin install utility-skills@baoyu-skills
``` ```
**Option 3: Ask the Agent** **Option 3: Ask the Agent**
@@ -70,13 +68,13 @@ Simply tell Claude Code:
> Please install Skills from github.com/JimLiu/baoyu-skills > Please install Skills from github.com/JimLiu/baoyu-skills
### Available Plugins ### Available Plugin
| Plugin | Description | Skills | The marketplace now exposes a single plugin so each skill is registered exactly once.
|--------|-------------|--------|
| **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) | | Plugin | Description | Includes |
| **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 | [youtube-transcript](#baoyu-youtube-transcript), [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** | 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 ## Update Skills
@@ -665,7 +663,7 @@ AI-powered generation backends.
#### baoyu-image-gen #### baoyu-image-gen
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), Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
```bash ```bash
# Basic generation (auto-detect provider) # Basic generation (auto-detect provider)
@@ -680,6 +678,9 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
# Specific provider # Specific provider
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai /baoyu-image-gen --prompt "A cat" --image cat.png --provider openai
# Azure OpenAI (model = deployment name)
/baoyu-image-gen --prompt "A cat" --image cat.png --provider azure --model gpt-image-1.5
# OpenRouter # OpenRouter
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openrouter /baoyu-image-gen --prompt "A cat" --image cat.png --provider openrouter
@@ -695,7 +696,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
# Seedream (豆包) # Seedream (豆包)
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
# With reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 5.0/4.5/4.0) # With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, or Seedream 5.0/4.5/4.0)
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png /baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
``` ```
+12 -11
View File
@@ -52,16 +52,14 @@ clawhub install baoyu-markdown-to-html
1. 选择 **Browse and install plugins** 1. 选择 **Browse and install plugins**
2. 选择 **baoyu-skills** 2. 选择 **baoyu-skills**
3. 选择要安装的插件 3. 选择 **baoyu-skills** 插件
4. 选择 **Install now** 4. 选择 **Install now**
**方式二:直接安装** **方式二:直接安装**
```bash ```bash
# 安装指定插件 # 安装 marketplace 中唯一的插件
/plugin install content-skills@baoyu-skills /plugin install baoyu-skills@baoyu-skills
/plugin install ai-generation-skills@baoyu-skills
/plugin install utility-skills@baoyu-skills
``` ```
**方式三:告诉 Agent** **方式三:告诉 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) | | **baoyu-skills** | 提供内容生成、AI 后端和日常效率工具技能 | 仓库中的全部 skills,仍按下方的内容技能、AI 生成技能、工具技能三个分类展示 |
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
| **utility-skills** | 内容处理工具 | [youtube-transcript](#baoyu-youtube-transcript), [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) |
## 更新技能 ## 更新技能
@@ -665,7 +663,7 @@ AI 驱动的生成后端。
#### baoyu-image-gen #### baoyu-image-gen
基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比和质量预设。 基于 AI SDK 的图像生成,支持 OpenAI、Azure OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
```bash ```bash
# 基础生成(自动检测服务商) # 基础生成(自动检测服务商)
@@ -680,6 +678,9 @@ AI 驱动的生成后端。
# 指定服务商 # 指定服务商
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai /baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai
# Azure OpenAImodel 为部署名称)
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider azure --model gpt-image-1.5
# OpenRouter # OpenRouter
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openrouter /baoyu-image-gen --prompt "一只猫" --image cat.png --provider openrouter
@@ -695,7 +696,7 @@ AI 驱动的生成后端。
# 豆包(Seedream # 豆包(Seedream
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
# 带参考图(Google、OpenAI、OpenRouter、Replicate 或 Seedream 5.0/4.5/4.0 # 带参考图(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate 或 Seedream 5.0/4.5/4.0
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png /baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
``` ```
+11 -9
View File
@@ -34,20 +34,22 @@ metadata:
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter 1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
2. Add TypeScript in `skills/baoyu-<name>/scripts/` (if applicable) 2. Add TypeScript in `skills/baoyu-<name>/scripts/` (if applicable)
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed 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 5. Add Script Directory section to SKILL.md if skill has scripts
6. Add openclaw metadata to frontmatter 6. Add openclaw metadata to frontmatter
## Category Selection ## Skill Grouping
| If your skill... | Use category | All skills are registered under the single `baoyu-skills` plugin. Use these logical groups when deciding where the skill should appear in the docs:
|------------------|--------------|
| 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` |
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 ## Writing Descriptions
+105 -1
View File
@@ -9,7 +9,11 @@
"packages/*" "packages/*"
], ],
"devDependencies": { "devDependencies": {
"tsx": "^4.20.5" "@mozilla/readability": "^0.6.0",
"linkedom": "^0.18.12",
"tsx": "^4.20.5",
"turndown": "^7.2.2",
"turndown-plugin-gfm": "^1.0.2"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -454,6 +458,23 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@mozilla/readability": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz",
"integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -615,6 +636,13 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/cssom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -896,6 +924,13 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
@@ -984,6 +1019,51 @@
"node": ">=18.17" "node": ">=18.17"
} }
}, },
"node_modules/linkedom": {
"version": "0.18.12",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz",
"integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"css-select": "^5.1.0",
"cssom": "^0.5.0",
"html-escaper": "^3.0.3",
"htmlparser2": "^10.0.0",
"uhyphen": "^0.2.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"canvas": ">= 2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/linkedom/node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/longest-streak": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -1768,6 +1848,30 @@
"fsevents": "~2.3.3" "fsevents": "~2.3.3"
} }
}, },
"node_modules/turndown": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"dev": true,
"license": "MIT"
},
"node_modules/uhyphen": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"dev": true,
"license": "ISC"
},
"node_modules/undici": { "node_modules/undici": {
"version": "6.24.0", "version": "6.24.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz",
+4
View File
@@ -10,6 +10,10 @@
"test:coverage": "node --import tsx --experimental-test-coverage --test" "test:coverage": "node --import tsx --experimental-test-coverage --test"
}, },
"devDependencies": { "devDependencies": {
"@mozilla/readability": "^0.6.0",
"linkedom": "^0.18.12",
"turndown": "^7.2.2",
"turndown-plugin-gfm": "^1.0.2",
"tsx": "^4.20.5" "tsx": "^4.20.5"
} }
} }
@@ -280,5 +280,5 @@ TEXTURE: Halftone transitions between sides
If watermark enabled in preferences, append: 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].
``` ```
+4 -5
View File
@@ -278,7 +278,7 @@ Create storyboard and character definitions using the confirmed style from Step
| Role | Character | Visual Description | | Role | Character | Visual Description |
|------|-----------|-------------------| |------|-----------|-------------------|
| Student | 大雄 (Nobita) | Japanese boy, 10yo, round glasses, black hair parted in middle, yellow shirt, navy shorts | | 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 | | Challenge | 胖虎 (Gian) | Stocky boy, rough features, small eyes, orange shirt |
| Support | 静香 (Shizuka) | Cute girl, black short hair, pink dress, gentle expression | | 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): **Watermark Application** (if enabled in preferences):
Add to each prompt: Add to each prompt:
``` ```
Include a subtle watermark "[content]" positioned at [position] Include a subtle watermark "[content]" positioned at [position]. The watermark should
with approximately [opacity*100]% visibility. The watermark should
be legible but not distracting from the comic panels and storytelling. be legible but not distracting from the comic panels and storytelling.
Ensure watermark does not overlap speech bubbles or key action. Ensure watermark does not overlap speech bubbles or key action.
``` ```
@@ -452,8 +451,8 @@ When skill does NOT support reference images, create combined prompt files:
## Character Reference (maintain consistency) ## Character Reference (maintain consistency)
[Copy relevant sections from characters/characters.md here] [Copy relevant sections from characters/characters.md here]
- 大雄: Japanese boy, round glasses, yellow shirt, navy shorts... - 大雄Japanese boy, round glasses, yellow shirt, navy shorts...
- 哆啦A梦: Round blue robot cat, white belly, red nose, golden bell... - 哆啦 A 梦:Round blue robot cat, white belly, red nose, golden bell...
## Page Content ## Page Content
[Original page prompt here] [Original page prompt here]
+4 -5
View File
@@ -162,15 +162,14 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
5. **Detect language**: Compare source, user input, EXTEND.md preference 5. **Detect language**: Compare source, user input, EXTEND.md preference
6. **Determine output directory**: Per File Structure rules 6. **Determine output directory**: Per File Structure rules
**⚠️ People in Reference Images — MUST follow all 3 rules:** **⚠️ People in Reference Images:**
If reference images contain **people** who should appear in the cover: If reference images contain **people** who should appear in the cover:
1. **`usage: direct`** — MUST set in refs description file. NEVER use `style` or `palette` when people need to appear - **Model supports `--ref`** (default): Copy image to `refs/`, pass via `--ref` at generation. No description file needed — the model sees the face directly.
2. **Per-character description** — MUST describe each person's distinctive features (hair, glasses, skin tone, clothing) in `refs/ref-NN-{slug}.md`. Vague descriptions like "a man" will fail - **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.
3. **`--ref` flag** — MUST pass reference image via `--ref` in Step 4 so the model sees actual faces
See [reference-images.md § Character Analysis](references/workflow/reference-images.md) for description format. See [reference-images.md](references/workflow/reference-images.md) for full decision table.
### Step 2: Confirm Options ⚠️ ### Step 2: Confirm Options ⚠️
@@ -16,17 +16,24 @@ Guide for processing user-provided reference images in cover generation.
**If user provides file path**: **If user provides file path**:
1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...) 1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...)
2. Create description: `refs/ref-NN-{slug}.md` 2. **Only** create description file `refs/ref-NN-{slug}.md` when model does NOT support `--ref` (see below)
3. Verify files exist before proceeding 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 ```yaml
--- ---
ref_id: NN ref_id: NN
filename: ref-NN-{slug}.{ext} filename: ref-NN-{slug}.{ext}
usage: direct | style | palette usage: direct | style | palette
--- ---
[User's description or auto-generated description] [Character or style description to embed in prompt]
``` ```
| Usage | When to Use | | Usage | When to Use |
+16 -6
View File
@@ -1,6 +1,6 @@
--- ---
name: baoyu-image-gen 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. description: AI image generation with OpenAI, Azure 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.3 version: 1.56.3
metadata: metadata:
openclaw: openclaw:
@@ -13,7 +13,7 @@ metadata:
# Image Generation (AI SDK) # 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 (阿里通义万象), Jimeng (即梦), Seedream (豆包) and Replicate providers.
## Script Directory ## Script Directory
@@ -74,12 +74,15 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
# From prompt files # From prompt files
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png ${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
# With reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0) # With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0)
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png ${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
# With reference images (explicit provider/model) # 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 ${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) # OpenRouter (recommended default model)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter
@@ -147,13 +150,13 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
| `--image <path>` | Output image path (required in single-image mode) | | `--image <path>` | Output image path (required in single-image mode) |
| `--batchfile <path>` | JSON batch file for multi-image generation | | `--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) | | `--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) | | `--provider google\|openai\|azure\|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`; DashScope: `qwen-image-2.0-pro`) | | `--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`) |
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) | | `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
| `--size <WxH>` | Size (e.g., `1024x1024`) | | `--size <WxH>` | Size (e.g., `1024x1024`) |
| `--quality normal\|2k` | Quality preset (default: `2k`) | | `--quality normal\|2k` | Quality preset (default: `2k`) |
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) | | `--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, Replicate, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 | | `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate, 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 | | `--n <count>` | Number of images |
| `--json` | JSON output | | `--json` | JSON output |
@@ -162,6 +165,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `OPENAI_API_KEY` | OpenAI API key | | `OPENAI_API_KEY` | OpenAI API key |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key |
| `OPENROUTER_API_KEY` | OpenRouter API key | | `OPENROUTER_API_KEY` | OpenRouter API key |
| `GOOGLE_API_KEY` | Google API key | | `GOOGLE_API_KEY` | Google API key |
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) | | `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
@@ -170,6 +174,8 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key | | `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key |
| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key | | `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key |
| `OPENAI_IMAGE_MODEL` | OpenAI model override | | `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`) | | `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
| `GOOGLE_IMAGE_MODEL` | Google model override | | `GOOGLE_IMAGE_MODEL` | Google model override |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) | | `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) |
@@ -177,6 +183,8 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) | | `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) | | `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | | `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_BASE_URL` | Custom OpenRouter endpoint (default: `https://openrouter.ai/api/v1`) |
| `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution | | `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution |
| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution | | `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution |
@@ -201,6 +209,8 @@ Model priority (highest → lowest), applies to all providers:
3. Env var: `<PROVIDER>_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`) 3. Env var: `<PROVIDER>_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`)
4. Built-in default 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. **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: **Agent MUST display model info** before each generation:
@@ -47,6 +47,8 @@ options:
description: "Gemini multimodal - high quality, reference images, flexible sizes" description: "Gemini multimodal - high quality, reference images, flexible sizes"
- label: "OpenAI" - label: "OpenAI"
description: "GPT Image - consistent quality, reliable output" description: "GPT Image - consistent quality, reliable output"
- label: "Azure OpenAI"
description: "Azure-hosted GPT Image deployments with resource-specific routing"
- label: "OpenRouter" - label: "OpenRouter"
description: "Router for Gemini/FLUX/OpenAI-compatible image models" description: "Router for Gemini/FLUX/OpenAI-compatible image models"
- label: "DashScope" - label: "DashScope"
@@ -87,6 +89,20 @@ options:
description: "Strong text-to-image quality through OpenRouter" 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 3: Default Quality ### Question 3: Default Quality
```yaml ```yaml
@@ -130,6 +146,7 @@ default_image_size: null
default_model: default_model:
google: [selected google model or null] google: [selected google model or null]
openai: null openai: null
azure: [selected azure deployment or null]
openrouter: [selected openrouter model or null] openrouter: [selected openrouter model or null]
dashscope: null dashscope: null
replicate: null replicate: null
@@ -166,6 +183,23 @@ options:
description: "Previous generation GPT Image model" 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-image-gen`, 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 ### OpenRouter Model Selection
```yaml ```yaml
@@ -230,6 +264,7 @@ After user selects a model:
default_model: default_model:
google: [value or null] google: [value or null]
openai: [value or null] openai: [value or null]
azure: [value or null]
openrouter: [value or null] openrouter: [value or null]
dashscope: [value or null] dashscope: [value or null]
replicate: [value or null] replicate: [value or null]
@@ -11,7 +11,7 @@ description: EXTEND.md YAML schema for baoyu-image-gen user preferences
--- ---
version: 1 version: 1
default_provider: null # google|openai|openrouter|dashscope|replicate|null (null = auto-detect) default_provider: null # google|openai|azure|openrouter|dashscope|replicate|null (null = auto-detect)
default_quality: null # normal|2k|null (null = use default: 2k) default_quality: null # normal|2k|null (null = use default: 2k)
@@ -22,6 +22,7 @@ default_image_size: null # 1K|2K|4K|null (Google/OpenRouter, overrides qualit
default_model: default_model:
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview" 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" 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" openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
dashscope: null # e.g., "qwen-image-2.0-pro" dashscope: null # e.g., "qwen-image-2.0-pro"
replicate: null # e.g., "google/nano-banana-pro" replicate: null # e.g., "google/nano-banana-pro"
@@ -38,6 +39,9 @@ batch:
openai: openai:
concurrency: 3 concurrency: 3
start_interval_ms: 1100 start_interval_ms: 1100
azure:
concurrency: 3
start_interval_ms: 1100
openrouter: openrouter:
concurrency: 3 concurrency: 3
start_interval_ms: 1100 start_interval_ms: 1100
@@ -58,6 +62,7 @@ batch:
| `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) | | `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) |
| `default_model.google` | string\|null | null | Google default model | | `default_model.google` | string\|null | null | Google default model |
| `default_model.openai` | string\|null | null | OpenAI 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.openrouter` | string\|null | null | OpenRouter default model |
| `default_model.dashscope` | string\|null | null | DashScope default model | | `default_model.dashscope` | string\|null | null | DashScope default model |
| `default_model.replicate` | string\|null | null | Replicate default model | | `default_model.replicate` | string\|null | null | Replicate default model |
@@ -87,6 +92,7 @@ default_image_size: 2K
default_model: default_model:
google: "gemini-3-pro-image-preview" google: "gemini-3-pro-image-preview"
openai: "gpt-image-1.5" openai: "gpt-image-1.5"
azure: "gpt-image-1.5"
openrouter: "google/gemini-3.1-flash-image-preview" openrouter: "google/gemini-3.1-flash-image-preview"
dashscope: "qwen-image-2.0-pro" dashscope: "qwen-image-2.0-pro"
replicate: "google/nano-banana-pro" replicate: "google/nano-banana-pro"
@@ -96,6 +102,9 @@ batch:
replicate: replicate:
concurrency: 5 concurrency: 5
start_interval_ms: 700 start_interval_ms: 700
azure:
concurrency: 3
start_interval_ms: 1100
openrouter: openrouter:
concurrency: 3 concurrency: 3
start_interval_ms: 1100 start_interval_ms: 1100
@@ -123,6 +123,7 @@ default_image_size: 2K
default_model: default_model:
google: gemini-3-pro-image-preview google: gemini-3-pro-image-preview
openai: gpt-image-1.5 openai: gpt-image-1.5
azure: image-prod
batch: batch:
max_workers: 8 max_workers: 8
provider_limits: provider_limits:
@@ -131,6 +132,9 @@ batch:
start_interval_ms: 900 start_interval_ms: 900
openai: openai:
concurrency: 4 concurrency: 4
azure:
concurrency: 1
start_interval_ms: 1500
`; `;
const config = parseSimpleYaml(yaml); const config = parseSimpleYaml(yaml);
@@ -142,6 +146,7 @@ batch:
assert.equal(config.default_image_size, "2K"); assert.equal(config.default_image_size, "2K");
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview"); 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?.openai, "gpt-image-1.5");
assert.equal(config.default_model?.azure, "image-prod");
assert.equal(config.batch?.max_workers, 8); assert.equal(config.batch?.max_workers, 8);
assert.deepEqual(config.batch?.provider_limits?.google, { assert.deepEqual(config.batch?.provider_limits?.google, {
concurrency: 2, concurrency: 2,
@@ -150,6 +155,10 @@ batch:
assert.deepEqual(config.batch?.provider_limits?.openai, { assert.deepEqual(config.batch?.provider_limits?.openai, {
concurrency: 4, concurrency: 4,
}); });
assert.deepEqual(config.batch?.provider_limits?.azure, {
concurrency: 1,
start_interval_ms: 1500,
});
}); });
test("mergeConfig only fills values missing from CLI args", () => { test("mergeConfig only fills values missing from CLI args", () => {
@@ -203,6 +212,8 @@ test("detectProvider selects an available ref-capable provider for reference-ima
useEnv(t, { useEnv(t, {
GOOGLE_API_KEY: null, GOOGLE_API_KEY: null,
OPENAI_API_KEY: "openai-key", OPENAI_API_KEY: "openai-key",
AZURE_OPENAI_API_KEY: null,
AZURE_OPENAI_BASE_URL: null,
OPENROUTER_API_KEY: null, OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null, DASHSCOPE_API_KEY: null,
REPLICATE_API_TOKEN: null, REPLICATE_API_TOKEN: null,
@@ -216,6 +227,27 @@ 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,
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) => { test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
useEnv(t, { useEnv(t, {
GOOGLE_API_KEY: null, GOOGLE_API_KEY: null,
+27 -10
View File
@@ -60,6 +60,7 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
dashscope: { concurrency: 3, startIntervalMs: 1100 }, dashscope: { concurrency: 3, startIntervalMs: 1100 },
jimeng: { concurrency: 3, startIntervalMs: 1100 }, jimeng: { concurrency: 3, startIntervalMs: 1100 },
seedream: { concurrency: 3, startIntervalMs: 1100 }, seedream: { concurrency: 3, startIntervalMs: 1100 },
azure: { concurrency: 3, startIntervalMs: 1100 },
}; };
function printUsage(): void { function printUsage(): void {
@@ -74,13 +75,13 @@ Options:
--image <path> Output image path (required in single-image mode) --image <path> Output image path (required in single-image mode)
--batchfile <path> JSON batch file for multi-image generation --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) --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|replicate|jimeng|seedream|azure Force provider (auto-detect by default)
-m, --model <id> Model ID -m, --model <id> Model ID
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3) --ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
--size <WxH> Size (e.g., 1024x1024) --size <WxH> Size (e.g., 1024x1024)
--quality normal|2k Quality preset (default: 2k) --quality normal|2k Quality preset (default: 2k)
--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality) --imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)
--ref <files...> Reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0) --ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0)
--n <count> Number of images for the current task (default: 1) --n <count> Number of images for the current task (default: 1)
--json JSON output --json JSON output
-h, --help Show help -h, --help Show help
@@ -131,6 +132,11 @@ Environment variables:
DASHSCOPE_BASE_URL Custom DashScope endpoint DASHSCOPE_BASE_URL Custom DashScope endpoint
REPLICATE_BASE_URL Custom Replicate endpoint REPLICATE_BASE_URL Custom Replicate endpoint
JIMENG_BASE_URL Custom Jimeng 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 SEEDREAM_BASE_URL Custom Seedream endpoint
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
@@ -231,7 +237,8 @@ export function parseArgs(argv: string[]): CliArgs {
v !== "dashscope" && v !== "dashscope" &&
v !== "replicate" && v !== "replicate" &&
v !== "jimeng" && v !== "jimeng" &&
v !== "seedream" v !== "seedream" &&
v !== "azure"
) { ) {
throw new Error(`Invalid provider: ${v}`); throw new Error(`Invalid provider: ${v}`);
} }
@@ -386,6 +393,7 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
replicate: null, replicate: null,
jimeng: null, jimeng: null,
seedream: null, seedream: null,
azure: null,
}; };
currentKey = "default_model"; currentKey = "default_model";
currentProvider = null; currentProvider = null;
@@ -411,7 +419,8 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
key === "dashscope" || key === "dashscope" ||
key === "replicate" || key === "replicate" ||
key === "jimeng" || key === "jimeng" ||
key === "seedream" key === "seedream" ||
key === "azure"
) )
) { ) {
config.batch ??= {}; config.batch ??= {};
@@ -427,7 +436,8 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
key === "dashscope" || key === "dashscope" ||
key === "replicate" || key === "replicate" ||
key === "jimeng" || key === "jimeng" ||
key === "seedream" key === "seedream" ||
key === "azure"
) )
) { ) {
const cleaned = value.replace(/['"]/g, ""); const cleaned = value.replace(/['"]/g, "");
@@ -520,9 +530,10 @@ export function getConfiguredProviderRateLimits(
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope }, dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng }, jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream }, 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", "jimeng", "seedream", "azure"] as Provider[]) {
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`; const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
const extendLimit = extendConfig.batch?.provider_limits?.[provider]; const extendLimit = extendConfig.batch?.provider_limits?.[provider];
configured[provider] = { configured[provider] = {
@@ -581,18 +592,20 @@ export function detectProvider(args: CliArgs): Provider {
args.provider && args.provider &&
args.provider !== "google" && args.provider !== "google" &&
args.provider !== "openai" && args.provider !== "openai" &&
args.provider !== "azure" &&
args.provider !== "openrouter" && args.provider !== "openrouter" &&
args.provider !== "replicate" && args.provider !== "replicate" &&
args.provider !== "seedream" args.provider !== "seedream"
) { ) {
throw new Error( throw new Error(
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider openrouter (OpenRouter multimodal), --provider replicate, or --provider seedream for supported Seedream models." "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, or --provider seedream for supported Seedream models."
); );
} }
if (args.provider) return args.provider; if (args.provider) return args.provider;
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY); 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 hasOpenai = !!process.env.OPENAI_API_KEY;
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY; const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
const hasDashscope = !!process.env.DASHSCOPE_API_KEY; const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
@@ -611,17 +624,19 @@ export function detectProvider(args: CliArgs): Provider {
if (args.referenceImages.length > 0) { if (args.referenceImages.length > 0) {
if (hasGoogle) return "google"; if (hasGoogle) return "google";
if (hasOpenai) return "openai"; if (hasOpenai) return "openai";
if (hasAzure) return "azure";
if (hasOpenrouter) return "openrouter"; if (hasOpenrouter) return "openrouter";
if (hasReplicate) return "replicate"; if (hasReplicate) return "replicate";
if (hasSeedream) return "seedream"; if (hasSeedream) return "seedream";
throw new Error( throw new Error(
"Reference images require Google, OpenAI, OpenRouter, Replicate, or supported Seedream models. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, or ARK_API_KEY, or remove --ref." "Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, or supported Seedream models. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, or ARK_API_KEY, or remove --ref."
); );
} }
const available = [ const available = [
hasGoogle && "google", hasGoogle && "google",
hasOpenai && "openai", hasOpenai && "openai",
hasAzure && "azure",
hasOpenrouter && "openrouter", hasOpenrouter && "openrouter",
hasDashscope && "dashscope", hasDashscope && "dashscope",
hasReplicate && "replicate", hasReplicate && "replicate",
@@ -633,7 +648,7 @@ export function detectProvider(args: CliArgs): Provider {
if (available.length > 1) return available[0]!; if (available.length > 1) return available[0]!;
throw new Error( 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, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys." "Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
); );
} }
@@ -676,6 +691,7 @@ async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule; if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule; if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
if (provider === "seedream") return (await import("./providers/seedream")) 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; return (await import("./providers/openai")) as ProviderModule;
} }
@@ -704,6 +720,7 @@ function getModelForProvider(
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate; 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 === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream; 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(); return providerModule.getDefaultModel();
} }
@@ -923,7 +940,7 @@ async function runBatchTasks(
const acquireProvider = createProviderGate(providerRateLimits); const acquireProvider = createProviderGate(providerRateLimits);
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers); const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`); 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]; const limit = providerRateLimits[provider];
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`); 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-image-gen-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);
}
+2 -1
View File
@@ -1,4 +1,4 @@
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate" | "jimeng" | "seedream"; export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate" | "jimeng" | "seedream" | "azure";
export type Quality = "normal" | "2k"; export type Quality = "normal" | "2k";
export type CliArgs = { export type CliArgs = {
@@ -55,6 +55,7 @@ export type ExtendConfig = {
replicate: string | null; replicate: string | null;
jimeng: string | null; jimeng: string | null;
seedream: string | null; seedream: string | null;
azure: string | null;
}; };
batch?: { batch?: {
max_workers?: number | null; max_workers?: number | null;
@@ -694,6 +694,10 @@ async function main(): Promise<void> {
} }
const creds = loadCredentials(resolved); 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..."); console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret); const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
@@ -0,0 +1,139 @@
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 { loadCredentials } from "./wechat-extend-config.ts";
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
t.after(() => {
process.chdir(previous);
});
}
function useHome(t: TestContext, home: string): void {
const previous = process.env.HOME;
process.env.HOME = home;
t.after(() => {
if (previous === undefined) {
delete process.env.HOME;
return;
}
process.env.HOME = previous;
});
}
function useWechatEnv(
t: TestContext,
values: Partial<Record<"WECHAT_APP_ID" | "WECHAT_APP_SECRET", string | undefined>>,
): void {
const previous = {
WECHAT_APP_ID: process.env.WECHAT_APP_ID,
WECHAT_APP_SECRET: process.env.WECHAT_APP_SECRET,
};
if (values.WECHAT_APP_ID === undefined) {
delete process.env.WECHAT_APP_ID;
} else {
process.env.WECHAT_APP_ID = values.WECHAT_APP_ID;
}
if (values.WECHAT_APP_SECRET === undefined) {
delete process.env.WECHAT_APP_SECRET;
} else {
process.env.WECHAT_APP_SECRET = values.WECHAT_APP_SECRET;
}
t.after(() => {
if (previous.WECHAT_APP_ID === undefined) {
delete process.env.WECHAT_APP_ID;
} else {
process.env.WECHAT_APP_ID = previous.WECHAT_APP_ID;
}
if (previous.WECHAT_APP_SECRET === undefined) {
delete process.env.WECHAT_APP_SECRET;
} else {
process.env.WECHAT_APP_SECRET = previous.WECHAT_APP_SECRET;
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function writeEnvFile(root: string, content: string): Promise<void> {
const envPath = path.join(root, ".baoyu-skills", ".env");
await fs.mkdir(path.dirname(envPath), { recursive: true });
await fs.writeFile(envPath, content);
}
test("loadCredentials selects the first complete source without mixing values across sources", async (t) => {
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
const homeRoot = await makeTempDir("wechat-creds-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useWechatEnv(t, {
WECHAT_APP_ID: undefined,
WECHAT_APP_SECRET: "stale-secret-from-process-env",
});
await writeEnvFile(cwdRoot, "WECHAT_APP_ID=cwd-app-id\nWECHAT_APP_SECRET=cwd-app-secret\n");
await writeEnvFile(homeRoot, "WECHAT_APP_ID=home-app-id\nWECHAT_APP_SECRET=home-app-secret\n");
const credentials = loadCredentials();
assert.equal(credentials.appId, "cwd-app-id");
assert.equal(credentials.appSecret, "cwd-app-secret");
assert.equal(credentials.source, "<cwd>/.baoyu-skills/.env");
assert.deepEqual(credentials.skippedSources, [
"process.env missing WECHAT_APP_ID",
]);
});
test("loadCredentials prefers a complete process.env pair over lower-priority files", async (t) => {
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
const homeRoot = await makeTempDir("wechat-creds-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useWechatEnv(t, {
WECHAT_APP_ID: "env-app-id",
WECHAT_APP_SECRET: "env-app-secret",
});
await writeEnvFile(cwdRoot, "WECHAT_APP_ID=cwd-app-id\nWECHAT_APP_SECRET=cwd-app-secret\n");
await writeEnvFile(homeRoot, "WECHAT_APP_ID=home-app-id\nWECHAT_APP_SECRET=home-app-secret\n");
const credentials = loadCredentials();
assert.equal(credentials.appId, "env-app-id");
assert.equal(credentials.appSecret, "env-app-secret");
assert.equal(credentials.source, "process.env");
assert.deepEqual(credentials.skippedSources, []);
});
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
const homeRoot = await makeTempDir("wechat-creds-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useWechatEnv(t, {
WECHAT_APP_ID: "env-app-id",
WECHAT_APP_SECRET: undefined,
});
await writeEnvFile(cwdRoot, "WECHAT_APP_SECRET=cwd-app-secret\n");
assert.throws(
() => loadCredentials(),
/Incomplete credential sources skipped:\n- process\.env missing WECHAT_APP_SECRET\n- <cwd>\/\.baoyu-skills\/\.env missing WECHAT_APP_ID/,
);
});
@@ -196,48 +196,116 @@ function aliasToEnvKey(alias: string): string {
return alias.toUpperCase().replace(/-/g, "_"); return alias.toUpperCase().replace(/-/g, "_");
} }
export function loadCredentials(account?: ResolvedAccount): { appId: string; appSecret: string } { interface CredentialSource {
if (account?.app_id && account?.app_secret) { name: string;
return { appId: account.app_id, appSecret: account.app_secret }; appIdKey: string;
appSecretKey: string;
appId?: string;
appSecret?: string;
}
export interface LoadedCredentials {
appId: string;
appSecret: string;
source: string;
skippedSources: string[];
}
function normalizeCredentialValue(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function describeMissingKeys(source: CredentialSource): string {
const missingKeys: string[] = [];
if (!source.appId) missingKeys.push(source.appIdKey);
if (!source.appSecret) missingKeys.push(source.appSecretKey);
return `${source.name} missing ${missingKeys.join(" and ")}`;
}
function buildCredentialSource(
name: string,
values: Record<string, string | undefined>,
appIdKey: string,
appSecretKey: string,
): CredentialSource {
return {
name,
appIdKey,
appSecretKey,
appId: normalizeCredentialValue(values[appIdKey]),
appSecret: normalizeCredentialValue(values[appSecretKey]),
};
}
function resolveCredentialSource(
sources: CredentialSource[],
account?: ResolvedAccount,
): LoadedCredentials {
const skippedSources: string[] = [];
for (const source of sources) {
if (source.appId && source.appSecret) {
return {
appId: source.appId,
appSecret: source.appSecret,
source: source.name,
skippedSources,
};
}
if (source.appId || source.appSecret) {
skippedSources.push(describeMissingKeys(source));
}
} }
const hint = account?.alias ? ` (account: ${account.alias})` : "";
const partialHint = skippedSources.length > 0
? `\nIncomplete credential sources skipped:\n- ${skippedSources.join("\n- ")}`
: "";
throw new Error(
`Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` +
"Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file." +
partialHint
);
}
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env"); const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env"); const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
const cwdEnv = loadEnvFile(cwdEnvPath); const cwdEnv = loadEnvFile(cwdEnvPath);
const homeEnv = loadEnvFile(homeEnvPath); const homeEnv = loadEnvFile(homeEnvPath);
const sources: CredentialSource[] = [];
if (account?.app_id || account?.app_secret) {
sources.push({
name: account.alias ? `EXTEND.md account "${account.alias}"` : "EXTEND.md account config",
appIdKey: "app_id",
appSecretKey: "app_secret",
appId: normalizeCredentialValue(account.app_id),
appSecret: normalizeCredentialValue(account.app_secret),
});
}
const prefix = account?.alias ? `WECHAT_${aliasToEnvKey(account.alias)}_` : ""; const prefix = account?.alias ? `WECHAT_${aliasToEnvKey(account.alias)}_` : "";
let appId = "";
let appSecret = "";
if (prefix) { if (prefix) {
appId = process.env[`${prefix}APP_ID`] const prefixedKeyLabel = `${prefix}APP_ID/${prefix}APP_SECRET`;
|| cwdEnv[`${prefix}APP_ID`] sources.push(
|| homeEnv[`${prefix}APP_ID`] buildCredentialSource(`process.env (${prefixedKeyLabel})`, process.env, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
|| ""; buildCredentialSource(`<cwd>/.baoyu-skills/.env (${prefixedKeyLabel})`, cwdEnv, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
appSecret = process.env[`${prefix}APP_SECRET`] buildCredentialSource(`~/.baoyu-skills/.env (${prefixedKeyLabel})`, homeEnv, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
|| cwdEnv[`${prefix}APP_SECRET`]
|| homeEnv[`${prefix}APP_SECRET`]
|| "";
}
if (!appId) {
appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID || "";
}
if (!appSecret) {
appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET || "";
}
if (!appId || !appSecret) {
const hint = account?.alias ? ` (account: ${account.alias})` : "";
throw new Error(
`Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` +
"Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file."
); );
} }
return { appId, appSecret }; sources.push(
buildCredentialSource("process.env", process.env, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
buildCredentialSource("<cwd>/.baoyu-skills/.env", cwdEnv, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
buildCredentialSource("~/.baoyu-skills/.env", homeEnv, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
);
return resolveCredentialSource(sources, account);
} }
export function listAccounts(config: WechatExtendConfig): string[] { export function listAccounts(config: WechatExtendConfig): string[] {
+2
View File
@@ -123,6 +123,8 @@ ${BUN_X} {baseDir}/scripts/weibo-article.ts article.md --cover ./cover.jpg
- Title: 32 characters max (truncated with warning if longer) - Title: 32 characters max (truncated with warning if longer)
- Summary/导语: 44 characters max (auto-regenerated from content if longer) - Summary/导语: 44 characters max (auto-regenerated from content if longer)
**Markdown-to-HTML**: Do NOT pass any `--theme` parameter when converting markdown to HTML. Use the default theme (no theme argument).
**Article Workflow**: **Article Workflow**:
1. Opens `https://card.weibo.com/article/v3/editor` 1. Opens `https://card.weibo.com/article/v3/editor`
2. Clicks "写文章" button, waits for editor to become editable 2. Clicks "写文章" button, waits for editor to become editable
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"; import assert from "node:assert/strict";
import test from "node:test";
import { import {
createMarkdownDocument, createMarkdownDocument,
@@ -129,73 +130,77 @@ function parse(html: string, url: string) {
return tryUrlRuleParsers(html, url, baseMetadata); return tryUrlRuleParsers(html, url, baseMetadata);
} }
describe("url rule parsers", () => { test("parses archive.ph pages from CONTENT and restores the original URL", () => {
test("parses archive.ph pages from CONTENT and restores the original URL", () => { const result = parse(ARCHIVE_HTML, "https://archive.ph/SMcX5");
const result = parse(ARCHIVE_HTML, "https://archive.ph/SMcX5");
expect(result).not.toBeNull(); assert.ok(result);
expect(result?.conversionMethod).toBe("parser:archive-ph"); assert.equal(result.conversionMethod, "parser:archive-ph");
expect(result?.metadata.url).toBe( assert.equal(
"https://www.newscientist.com/article/2520204-major-leap-towards-reanimation-after-death-as-mammals-brain-preserved/" result.metadata.url,
); "https://www.newscientist.com/article/2520204-major-leap-towards-reanimation-after-death-as-mammals-brain-preserved/"
expect(result?.metadata.title).toBe( );
"Major leap towards reanimation after death as mammal brain preserved" assert.equal(
); result.metadata.title,
expect(result?.metadata.coverImage).toBe("https://cdn.example.com/brain.jpg"); "Major leap towards reanimation after death as mammal brain preserved"
expect(result?.markdown).toContain("Researchers say the preserved structure"); );
expect(result?.markdown).toContain("![Brain tissue](https://cdn.example.com/brain.jpg)"); assert.equal(result.metadata.coverImage, "https://cdn.example.com/brain.jpg");
expect(result?.markdown).not.toContain("Archive shell text that should be ignored"); assert.ok(result.markdown.includes("Researchers say the preserved structure"));
}); assert.ok(result.markdown.includes("![Brain tissue](https://cdn.example.com/brain.jpg)"));
assert.ok(!result.markdown.includes("Archive shell text that should be ignored"));
test("falls back to body when archive.ph CONTENT is missing", () => { });
const result = parse(ARCHIVE_FALLBACK_HTML, "https://archive.ph/fallback");
test("falls back to body when archive.ph CONTENT is missing", () => {
expect(result).not.toBeNull(); const result = parse(ARCHIVE_FALLBACK_HTML, "https://archive.ph/fallback");
expect(result?.conversionMethod).toBe("parser:archive-ph");
expect(result?.metadata.url).toBe("https://example.com/fallback-story"); assert.ok(result);
expect(result?.metadata.title).toBe("Fallback body parsing still works"); assert.equal(result.conversionMethod, "parser:archive-ph");
expect(result?.markdown).toContain("When CONTENT is absent"); assert.equal(result.metadata.url, "https://example.com/fallback-story");
}); assert.equal(result.metadata.title, "Fallback body parsing still works");
assert.ok(result.markdown.includes("When CONTENT is absent"));
test("parses X article pages from HTML", () => { });
const result = parse(
ARTICLE_HTML, test("parses X article pages from HTML", () => {
"https://x.com/dotey/article/2035141635713941927" const result = parse(
); ARTICLE_HTML,
"https://x.com/dotey/article/2035141635713941927"
expect(result).not.toBeNull(); );
expect(result?.conversionMethod).toBe("parser:x-article");
expect(result?.metadata.title).toBe("Karpathy\"写代码\"已经不是对的动词了"); assert.ok(result);
expect(result?.metadata.author).toBe("宝玉 (@dotey)"); assert.equal(result.conversionMethod, "parser:x-article");
expect(result?.metadata.coverImage).toBe("https://pbs.twimg.com/media/article-cover.jpg"); assert.equal(result.metadata.title, "Karpathy\"写代码\"已经不是对的动词了");
expect(result?.metadata.published).toBe("2026-03-20T23:49:11.000Z"); assert.equal(result.metadata.author, "宝玉 (@dotey)");
expect(result?.metadata.language).toBe("zh"); assert.equal(result.metadata.coverImage, "https://pbs.twimg.com/media/article-cover.jpg");
expect(result?.markdown).toContain("## 要点速览"); assert.equal(result.metadata.published, "2026-03-20T23:49:11.000Z");
expect(result?.markdown).toContain( assert.equal(result.metadata.language, "zh");
"[![](https://pbs.twimg.com/media/article-inline.jpg)](/dotey/article/2035141635713941927/media/2)" assert.ok(result.markdown.includes("## 要点速览"));
); assert.ok(
expect(result?.markdown).toContain("写代码已经不是对的动词了。"); result.markdown.includes(
"[![](https://pbs.twimg.com/media/article-inline.jpg)](/dotey/article/2035141635713941927/media/2)"
const document = createMarkdownDocument(result!); )
expect(document).toContain("# Karpathy\"写代码\"已经不是对的动词了"); );
}); assert.ok(result.markdown.includes("写代码已经不是对的动词了。"));
test("parses X status pages from HTML without duplicating the title heading", () => { const document = createMarkdownDocument(result);
const result = parse( assert.ok(document.includes("# Karpathy\"写代码\"已经不是对的动词了"));
STATUS_HTML, });
"https://x.com/dotey/status/2035590649081196710"
); test("parses X status pages from HTML without duplicating the title heading", () => {
const result = parse(
expect(result).not.toBeNull(); STATUS_HTML,
expect(result?.conversionMethod).toBe("parser:x-status"); "https://x.com/dotey/status/2035590649081196710"
expect(result?.metadata.author).toBe("宝玉 (@dotey)"); );
expect(result?.metadata.coverImage).toBe("https://pbs.twimg.com/media/tweet-main.jpg");
expect(result?.metadata.language).toBe("zh"); assert.ok(result);
expect(result?.markdown).toContain("转译:把下面这段加到你的 Codex 自定义指令里"); assert.equal(result.conversionMethod, "parser:x-status");
expect(result?.markdown).toContain("> Quote from Matt Shumer (@mattshumer_)"); assert.equal(result.metadata.author, "宝玉 (@dotey)");
expect(result?.markdown).toContain("!["); assert.equal(result.metadata.coverImage, "https://pbs.twimg.com/media/tweet-main.jpg");
assert.equal(result.metadata.language, "zh");
const document = createMarkdownDocument(result!); assert.ok(result.markdown.includes("转译:把下面这段加到你的 Codex 自定义指令里"));
expect(document).not.toContain("\n\n# 转译:把下面这段加到你的 Codex 自定义指令里,体验会好太多:\n\n"); assert.ok(result.markdown.includes("> Quote from Matt Shumer (@mattshumer_)"));
}); assert.ok(result.markdown.includes("!["));
const document = createMarkdownDocument(result);
assert.ok(
!document.includes("\n\n# 转译:把下面这段加到你的 Codex 自定义指令里,体验会好太多:\n\n")
);
}); });
@@ -151,8 +151,7 @@ From outline entry:
```markdown ```markdown
## Watermark ## Watermark
Include a subtle watermark "{content}" positioned at {position} Include a subtle watermark "{content}" positioned at {position}. The watermark should
with approximately {opacity*100}% visibility. The watermark should
be legible but not distracting from the main content. be legible but not distracting from the main content.
``` ```
@@ -295,19 +294,19 @@ Create a Xiaohongshu (Little Red Book) style infographic following these guideli
## Content ## Content
**Position**: Content (Page 3 of 6) **Position**: Content (Page 3 of 6)
**Core Message**: ChatGPT使用技巧 **Core Message**: ChatGPT 使用技巧
**Text Content**: **Text Content**:
- Title: 「ChatGPT」 - Title: 「ChatGPT」
- Subtitle: 最强AI助手 - Subtitle: 最强 AI 助手
- Points: - Points:
- 写文案:给出框架,秒出初稿 - 写文案:给出框架,秒出初稿
- 改文章:润色、翻译、总结 - 改文章:润色、翻译、总结
- 编程:写代码、找bug - 编程:写代码、找 bug
- 学习:解释概念、出题练习 - 学习:解释概念、出题练习
**Visual Concept**: **Visual Concept**:
ChatGPT logo居中,四周放射状展示功能点 ChatGPT logo 居中,四周放射状展示功能点
深色科技背景,霓虹绿点缀 深色科技背景,霓虹绿点缀
--- ---