mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 06:19:46 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ba68c9a9c | |||
| b3f5c0a8aa | |||
| 9c45ede0c7 | |||
| 30d2ac98ce | |||
| bfdd64bd4e | |||
| 434d4857da | |||
| 990fea4f7b | |||
| 517ff566a1 | |||
| 4c9af7d92f | |||
| 46c4859d48 | |||
| f3f886217b | |||
| 4d465d55d0 | |||
| 11d80eeaa9 | |||
| 58ba4579ef | |||
| 67a45a57a0 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.100.0"
|
||||
"version": "1.104.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.104.0 - 2026-04-13
|
||||
|
||||
### Features
|
||||
- `baoyu-diagram`: add Mermaid sketch step (6d-0) before SVG generation — write a Mermaid code block as structural intent; add Mermaid–SVG consistency check in step 6f
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: verify editor focus before paste and type operations to prevent silent paste failures
|
||||
|
||||
## 1.103.1 - 2026-04-13
|
||||
|
||||
### Fixes
|
||||
- `baoyu-markdown-to-html`: decode HTML entities and strip tags from article summary
|
||||
- `baoyu-post-to-weibo`: decode HTML entities and strip tags from article summary
|
||||
|
||||
## 1.103.0 - 2026-04-12
|
||||
|
||||
### Features
|
||||
- `baoyu-diagram`: add multi-diagram mode — analyze article content and generate multiple diagrams at identified positions; new `--density` option (`minimal`, `balanced`, `per-section`, `rich`) and `--mode` option (`single`, `multi`, `auto`); auto-detects mode from input (file path → multi, short topic → single); inserts diagram image links into article; output structure `diagram/{article-slug}/NN-{type}-{slug}/`
|
||||
|
||||
### Fixes
|
||||
- `baoyu-article-illustrator`: prevent color names and hex codes from appearing as visible text in generated images — add semantic constraint to all palette references and prompt construction rules
|
||||
- `baoyu-cover-image`: prevent color names and hex codes from appearing as visible text in generated images — add constraint to all palette references and prompt template
|
||||
- `baoyu-image-cards`: prevent color names from appearing as visible text in generated images
|
||||
- `baoyu-post-to-wechat`: decode HTML entities and strip HTML tags from article summary before using as WeChat article digest
|
||||
|
||||
## 1.102.0 - 2026-04-12
|
||||
|
||||
### Features
|
||||
- `baoyu-imagine`: add OpenAI-compatible image API dialect — new `--imageApiDialect` flag, `OPENAI_IMAGE_API_DIALECT` env var, and `default_image_api_dialect` config for gateways that expect aspect-ratio `size` plus `metadata.resolution` instead of pixel `size`
|
||||
|
||||
## 1.101.0 - 2026-04-12
|
||||
|
||||
### Features
|
||||
- `baoyu-imagine`: improve Replicate provider compatibility — route models through family-specific input builders and validators (nano-banana, Seedream 4.5, Seedream 5 Lite, Wan 2.7 Image); update default model to `google/nano-banana-2`; fix Seedream 4.5 custom size encoding to use width/height schema; fix aspect-ratio default inheritance for unsupported Replicate models; block multi-output requests before they reach the API (by @justnode)
|
||||
|
||||
## 1.100.0 - 2026-04-12
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.104.0 - 2026-04-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-diagram`:新增 Mermaid 草图步骤(6d-0),在生成 SVG 前先写 Mermaid 代码块作为结构意图;在步骤 6f 新增 Mermaid–SVG 一致性检查
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:在粘贴和输入操作前校验编辑器焦点,避免粘贴静默失败
|
||||
|
||||
## 1.103.1 - 2026-04-13
|
||||
|
||||
### 修复
|
||||
- `baoyu-markdown-to-html`:修复文章摘要中 HTML 实体未解码及 HTML 标签未剥离的问题
|
||||
- `baoyu-post-to-weibo`:修复文章摘要中 HTML 实体未解码及 HTML 标签未剥离的问题
|
||||
|
||||
## 1.103.0 - 2026-04-12
|
||||
|
||||
### 新功能
|
||||
- `baoyu-diagram`:新增多图模式 —— 分析文章内容,在识别出的位置批量生成图表;新增 `--density` 参数(`minimal`、`balanced`、`per-section`、`rich`)和 `--mode` 参数(`single`、`multi`、`auto`);根据输入自动判断模式(文件路径→多图,短主题→单图);自动在文章中插入图表链接;输出目录结构 `diagram/{article-slug}/NN-{type}-{slug}/`
|
||||
|
||||
### 修复
|
||||
- `baoyu-article-illustrator`:修复生成图像中出现颜色名称和色值文字的问题 —— 在所有调色板参考文件和提示构建规则中添加语义约束
|
||||
- `baoyu-cover-image`:修复生成图像中出现颜色名称和色值文字的问题 —— 在所有调色板参考文件和提示模板中添加约束
|
||||
- `baoyu-image-cards`:修复生成图像中出现颜色名称文字的问题
|
||||
- `baoyu-post-to-wechat`:修复文章摘要中 HTML 实体未解码及 HTML 标签未剥离的问题,避免微信文章摘要显示乱码
|
||||
|
||||
## 1.102.0 - 2026-04-12
|
||||
|
||||
### 新功能
|
||||
- `baoyu-imagine`:新增 OpenAI 兼容图像 API 方言支持 —— 新增 `--imageApiDialect` 参数、`OPENAI_IMAGE_API_DIALECT` 环境变量及 `default_image_api_dialect` 配置项,用于对接期望宽高比格式 `size` 加 `metadata.resolution` 的兼容网关
|
||||
|
||||
## 1.101.0 - 2026-04-12
|
||||
|
||||
### 新功能
|
||||
- `baoyu-imagine`:改进 Replicate 服务商兼容性 —— 针对不同模型系列(nano-banana、Seedream 4.5、Seedream 5 Lite、Wan 2.7 Image)实现专属输入构建器和验证器;将默认模型更新为 `google/nano-banana-2`;修复 Seedream 4.5 自定义尺寸编码(改用 width/height schema);修复不支持的 Replicate 模型的宽高比默认值继承问题;在请求到达 API 前拦截多图请求 (by @justnode)
|
||||
|
||||
## 1.100.0 - 2026-04-12
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.97.0**.
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.104.0**.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -274,19 +274,18 @@ Generate professional infographics with 21 layout types and 21 visual styles. An
|
||||
|
||||
#### baoyu-diagram
|
||||
|
||||
Generate publication-ready SVG diagrams — flowcharts, structural/architecture diagrams, and illustrative intuition diagrams. Claude writes real SVG code directly following a cohesive design system. Output is a single self-contained `.svg` file with embedded styles and auto dark-mode, ready to embed in articles, WeChat posts, slides, Notion, and docs.
|
||||
Generate publication-ready SVG diagrams — flowcharts, sequence/protocol diagrams, structural/architecture diagrams, and illustrative intuition diagrams. Supports single-diagram mode (one topic) and multi-diagram mode (analyze article content and generate diagrams at identified positions). Claude writes real SVG code directly following a cohesive design system. Output is self-contained `.svg` files with embedded styles and auto dark-mode.
|
||||
|
||||
```bash
|
||||
# Auto-route on the verb in the prompt
|
||||
# Single-diagram mode: auto-route on the verb in the prompt
|
||||
/baoyu-diagram "how JWT authentication works"
|
||||
|
||||
# Force a specific type
|
||||
/baoyu-diagram "Kubernetes architecture" --type structural
|
||||
/baoyu-diagram "how attention works" --type illustrative
|
||||
/baoyu-diagram "CI/CD pipeline" --type flowchart
|
||||
/baoyu-diagram "OAuth 2.0 flow" --type sequence
|
||||
|
||||
# From a markdown source file
|
||||
/baoyu-diagram path/to/content.md
|
||||
# Multi-diagram mode: analyze article and generate diagrams at identified positions
|
||||
/baoyu-diagram path/to/article.md
|
||||
/baoyu-diagram path/to/article.md --density balanced
|
||||
/baoyu-diagram path/to/article.md --density per-section --lang zh
|
||||
|
||||
# Language and output path
|
||||
/baoyu-diagram "微服务架构" --lang zh
|
||||
@@ -296,17 +295,21 @@ Generate publication-ready SVG diagrams — flowcharts, structural/architecture
|
||||
**Options**:
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--type <name>` | `flowchart`, `structural`, `illustrative`, `auto` (default) |
|
||||
| `--type <name>` | `flowchart`, `sequence`, `structural`, `illustrative`, `class`, `auto` (default). Forces single mode. |
|
||||
| `--lang <code>` | Output language (en, zh, ja, ...) |
|
||||
| `--out <path>` | Output file path (default: `diagram/{slug}/diagram.svg`) |
|
||||
| `--out <path>` | Output file path (default: `diagram/{slug}/diagram.svg`). Forces single mode. |
|
||||
| `--density <level>` | Multi mode only: `minimal` (1-2), `balanced` (3-5, default), `per-section`, `rich` (6+) |
|
||||
| `--mode <mode>` | `single`, `multi`, `auto` (default — detect from input) |
|
||||
|
||||
**Diagram types**:
|
||||
|
||||
| Type | Reader need | Verbs that trigger it |
|
||||
|------|-------------|------------------------|
|
||||
| `flowchart` | Walk me through the steps in order | walk through, steps, process, lifecycle, workflow, state machine |
|
||||
| `sequence` | Who talks to whom, in what order | protocol, handshake, auth flow, OAuth, TCP, request/response |
|
||||
| `structural` | Show me what's inside what, how it's organised | architecture, components, topology, layout, what's inside |
|
||||
| `illustrative` | Give me the intuition — draw the mechanism | how does X work, explain X, intuition for, why does X do Y |
|
||||
| `class` | What are the types and how are they related | class diagram, UML, inheritance, interface, schema |
|
||||
|
||||
Not an image-generation skill — no LLM image model is called. Claude writes the SVG by hand with hand-computed layout math, so every diagram honors the design system. Embedded `<style>` block with `@media (prefers-color-scheme: dark)` means the same file renders correctly in both light and dark mode anywhere it's embedded.
|
||||
|
||||
@@ -754,9 +757,15 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
# 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
|
||||
# Replicate (default: google/nano-banana-2)
|
||||
/baoyu-imagine --prompt "A cat" --image cat.png --provider replicate
|
||||
|
||||
# Replicate Seedream 4.5
|
||||
/baoyu-imagine --prompt "A studio portrait" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
|
||||
|
||||
# Replicate Wan 2.7 Image Pro
|
||||
/baoyu-imagine --prompt "A concept frame" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
|
||||
|
||||
# Jimeng (即梦)
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
@@ -784,8 +793,9 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
| `--size` | Size (e.g., `1024x1024`) |
|
||||
| `--quality` | `normal` or `2k` (default: `2k`) |
|
||||
| `--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 |
|
||||
| `--imageApiDialect` | `openai-native` or `ratio-metadata` for OpenAI-compatible gateways |
|
||||
| `--ref` | Reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate supported families, MiniMax, or Seedream 5.0/4.5/4.0) |
|
||||
| `--n` | Number of images per request (`replicate` currently requires `--n 1`) |
|
||||
| `--json` | JSON output |
|
||||
|
||||
**Environment Variables** (see [Environment Configuration](#environment-configuration) for setup):
|
||||
@@ -813,10 +823,11 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
| `ZAI_IMAGE_MODEL` | Z.AI model | `glm-image` |
|
||||
| `BIGMODEL_IMAGE_MODEL` | Backward-compatible alias for Z.AI model | `glm-image` |
|
||||
| `MINIMAX_IMAGE_MODEL` | MiniMax model | `image-01` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-2` |
|
||||
| `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_API_DIALECT` | OpenAI-compatible image API dialect (`openai-native` or `ratio-metadata`) | `openai-native` |
|
||||
| `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` |
|
||||
@@ -844,6 +855,9 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
- 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.
|
||||
- Replicate defaults to `google/nano-banana-2`. `baoyu-imagine` only enables Replicate advanced options for `google/nano-banana*`, `bytedance/seedream-4.5`, `bytedance/seedream-5-lite`, `wan-video/wan-2.7-image`, and `wan-video/wan-2.7-image-pro`.
|
||||
- Replicate currently saves exactly one output image per request. `--n > 1` is blocked locally instead of silently dropping extra results.
|
||||
- Replicate model behavior is family-specific: nano-banana uses `--quality` / `--ar`, Seedream uses validated `--size` / `--ar`, and Wan uses validated `--size` (with `--ar` converted locally to a concrete size).
|
||||
|
||||
**Provider Auto-Selection**:
|
||||
1. If `--provider` is specified → use it
|
||||
@@ -1161,7 +1175,7 @@ MINIMAX_IMAGE_MODEL=image-01
|
||||
|
||||
# Replicate
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-2
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# Jimeng (即梦)
|
||||
|
||||
+30
-16
@@ -274,19 +274,18 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
#### baoyu-diagram
|
||||
|
||||
生成可直接发布的 SVG 图表 —— 包括流程图、架构/结构图、示意图(直觉图解)。Claude 直接输出符合统一设计规范的真实 SVG 代码,产物是单个自包含的 `.svg` 文件,内嵌样式并自动支持深色模式,可直接嵌入文章、微信公众号、幻灯片、Notion 和各类文档中。
|
||||
生成可直接发布的 SVG 图表 —— 包括流程图、时序/协议图、架构/结构图、示意图(直觉图解)。支持单图模式(针对一个主题)和多图模式(分析文章内容,在识别出的位置批量生成图表)。Claude 直接输出符合统一设计规范的真实 SVG 代码,产物是自包含的 `.svg` 文件,内嵌样式并自动支持深色模式。
|
||||
|
||||
```bash
|
||||
# 自动根据提示词中的动词路由类型
|
||||
# 单图模式:自动根据提示词中的动词路由类型
|
||||
/baoyu-diagram "JWT 认证流程是怎么工作的"
|
||||
|
||||
# 强制指定类型
|
||||
/baoyu-diagram "Kubernetes 架构" --type structural
|
||||
/baoyu-diagram "注意力机制原理" --type illustrative
|
||||
/baoyu-diagram "CI/CD 流水线" --type flowchart
|
||||
/baoyu-diagram "OAuth 2.0 流程" --type sequence
|
||||
|
||||
# 从 Markdown 源文件生成
|
||||
/baoyu-diagram path/to/content.md
|
||||
# 多图模式:分析文章并在识别出的位置生成图表
|
||||
/baoyu-diagram path/to/article.md
|
||||
/baoyu-diagram path/to/article.md --density balanced
|
||||
/baoyu-diagram path/to/article.md --density per-section --lang zh
|
||||
|
||||
# 语言和输出路径
|
||||
/baoyu-diagram "微服务架构" --lang zh
|
||||
@@ -296,17 +295,21 @@ clawhub install baoyu-markdown-to-html
|
||||
**参数**:
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--type <name>` | `flowchart`(流程图)、`structural`(结构/架构图)、`illustrative`(示意图)、`auto`(默认,按动词路由) |
|
||||
| `--type <name>` | `flowchart`、`sequence`、`structural`、`illustrative`、`class`、`auto`(默认)。强制单图模式。 |
|
||||
| `--lang <code>` | 输出语言(en、zh、ja 等) |
|
||||
| `--out <path>` | 输出文件路径(默认:`diagram/{slug}/diagram.svg`) |
|
||||
| `--out <path>` | 输出文件路径(默认:`diagram/{slug}/diagram.svg`)。强制单图模式。 |
|
||||
| `--density <level>` | 仅多图模式:`minimal`(1-2 张)、`balanced`(3-5 张,默认)、`per-section`、`rich`(6+ 张) |
|
||||
| `--mode <mode>` | `single`、`multi`、`auto`(默认,自动根据输入判断) |
|
||||
|
||||
**三种图表类型**:
|
||||
**五种图表类型**:
|
||||
|
||||
| 类型 | 适用场景 | 触发动词 |
|
||||
|------|----------|----------|
|
||||
| `flowchart` | 按顺序走一遍流程 | 流程、步骤、工作流、生命周期、状态机 |
|
||||
| `sequence` | 谁和谁通信、按什么顺序 | 协议、握手、认证流程、OAuth、TCP、请求/响应 |
|
||||
| `structural` | 展示什么包含什么、如何组织 | 架构、组件、拓扑、布局、什么在什么里面 |
|
||||
| `illustrative` | 建立直觉 —— 画出机制本身 | 怎么工作、原理、为什么、直观解释 |
|
||||
| `class` | 类型是什么、它们如何关联 | 类图、UML、继承、接口、数据模型 |
|
||||
|
||||
本技能不调用任何图像生成模型 —— Claude 通过手算坐标直接写 SVG 代码,确保每个图表都遵守设计规范。内嵌的 `<style>` 块包含 `@media (prefers-color-scheme: dark)`,同一个文件在浅色和深色模式下均正确渲染,可嵌入到任意支持 SVG 的宿主环境中。
|
||||
|
||||
@@ -754,9 +757,15 @@ AI 驱动的生成后端。
|
||||
# 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
|
||||
# Replicate(默认:google/nano-banana-2)
|
||||
/baoyu-imagine --prompt "一只猫" --image cat.png --provider replicate
|
||||
|
||||
# Replicate Seedream 4.5
|
||||
/baoyu-imagine --prompt "一张影棚人像" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
|
||||
|
||||
# Replicate Wan 2.7 Image Pro
|
||||
/baoyu-imagine --prompt "一张概念分镜" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
|
||||
|
||||
# 即梦(Jimeng)
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
@@ -784,8 +793,9 @@ AI 驱动的生成后端。
|
||||
| `--size` | 尺寸(如 `1024x1024`) |
|
||||
| `--quality` | `normal` 或 `2k`(默认:`2k`) |
|
||||
| `--imageSize` | Google/OpenRouter 使用的 `1K`、`2K`、`4K` |
|
||||
| `--ref` | 参考图片(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate、MiniMax 或 Seedream 5.0/4.5/4.0) |
|
||||
| `--n` | 单次请求生成图片数量 |
|
||||
| `--imageApiDialect` | OpenAI 兼容网关的图像 API 方言(`openai-native` 或 `ratio-metadata`) |
|
||||
| `--ref` | 参考图片(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate 支持的模型家族、MiniMax 或 Seedream 5.0/4.5/4.0) |
|
||||
| `--n` | 单次请求生成图片数量(`replicate` 当前只支持 `--n 1`) |
|
||||
| `--json` | 输出 JSON 结果 |
|
||||
|
||||
**环境变量**(配置方法见[环境配置](#环境配置)):
|
||||
@@ -813,10 +823,11 @@ AI 驱动的生成后端。
|
||||
| `ZAI_IMAGE_MODEL` | Z.AI 模型 | `glm-image` |
|
||||
| `BIGMODEL_IMAGE_MODEL` | Z.AI 模型向后兼容别名 | `glm-image` |
|
||||
| `MINIMAX_IMAGE_MODEL` | MiniMax 模型 | `image-01` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-2` |
|
||||
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
||||
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
||||
| `OPENAI_IMAGE_API_DIALECT` | OpenAI 兼容图像 API 方言(`openai-native` 或 `ratio-metadata`) | `openai-native` |
|
||||
| `OPENAI_IMAGE_USE_CHAT` | OpenAI 改走 `/chat/completions` | `false` |
|
||||
| `AZURE_OPENAI_BASE_URL` | Azure 资源或部署端点 | - |
|
||||
| `AZURE_API_VERSION` | Azure 图像 API 版本 | `2025-04-01-preview` |
|
||||
@@ -844,6 +855,9 @@ AI 驱动的生成后端。
|
||||
- MiniMax 参考图会走 `subject_reference`,当前能力更偏角色 / 人像一致性。
|
||||
- 即梦不支持参考图。
|
||||
- 豆包参考图能力仅适用于 Seedream 5.0 / 4.5 / 4.0,不适用于 Seedream 3.0。
|
||||
- Replicate 默认模型改为 `google/nano-banana-2`。`baoyu-imagine` 目前只对 `google/nano-banana*`、`bytedance/seedream-4.5`、`bytedance/seedream-5-lite`、`wan-video/wan-2.7-image` 和 `wan-video/wan-2.7-image-pro` 开启本地能力识别与校验。
|
||||
- Replicate 当前只保存单张输出图,`--n > 1` 会在本地直接报错,避免多图结果被静默丢弃。
|
||||
- Replicate 的参数能力按模型家族区分:nano-banana 走 `--quality` / `--ar`,Seedream 走校验后的 `--size` / `--ar`,Wan 走校验后的 `--size`(`--ar` 会先在本地换算成具体尺寸)。
|
||||
|
||||
**服务商自动选择**:
|
||||
1. 如果指定了 `--provider` → 使用指定的
|
||||
@@ -1161,7 +1175,7 @@ MINIMAX_IMAGE_MODEL=image-01
|
||||
|
||||
# Replicate
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-2
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# 即梦(Jimeng)
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
cleanSummaryText,
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
parseFrontmatter,
|
||||
@@ -91,3 +92,19 @@ This is **the first paragraph** with [a link](https://example.com) and \`inline
|
||||
"This is the first paragraph with a link and inline code that should...",
|
||||
);
|
||||
});
|
||||
|
||||
test("summary extraction normalizes raw HTML paragraphs to plain text", () => {
|
||||
const summary = extractSummaryFromBody(
|
||||
`
|
||||
# Heading
|
||||
<p style="font-size: 16px; color: #666; margin-bottom: 20px;">2026年初,一只“龙虾”搅动了整个科技圈。腾讯楼下排起近千人长队,只为让工程师领取一份福利。</p>
|
||||
`,
|
||||
120,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
summary,
|
||||
"2026年初,一只“龙虾”搅动了整个科技圈。腾讯楼下排起近千人长队,只为让工程师领取一份福利。",
|
||||
);
|
||||
assert.equal(cleanSummaryText("<strong>Good text!'</strong>"), "Good text!'");
|
||||
});
|
||||
|
||||
@@ -46,6 +46,45 @@ export function stripWrappingQuotes(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
apos: "'",
|
||||
gt: ">",
|
||||
lt: "<",
|
||||
nbsp: " ",
|
||||
quot: '"',
|
||||
};
|
||||
|
||||
function decodeHtmlCodePoint(codePoint: number, fallback: string): string {
|
||||
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, body: string) => {
|
||||
const normalized = body.toLowerCase();
|
||||
if (normalized.startsWith("#x")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(2), 16), entity);
|
||||
}
|
||||
if (normalized.startsWith("#")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(1), 10), entity);
|
||||
}
|
||||
return HTML_ENTITIES[normalized] ?? entity;
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanSummaryText(value: string): string {
|
||||
return decodeHtmlEntities(stripWrappingQuotes(value))
|
||||
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?[a-z][a-z0-9:-]*(?:\s+[^>]*)?>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return stripWrappingQuotes(value);
|
||||
@@ -94,10 +133,11 @@ export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1");
|
||||
const summaryText = cleanSummaryText(cleanText);
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
if (cleanText.length <= maxLen) return cleanText;
|
||||
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||
if (summaryText.length > 20) {
|
||||
if (summaryText.length <= maxLen) return summaryText;
|
||||
return `${summaryText.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,22 @@ test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
|
||||
assert.match(html, /<article>Hello<\/article>/);
|
||||
});
|
||||
|
||||
test("buildHtmlDocument escapes head metadata attributes", () => {
|
||||
const html = buildHtmlDocument(
|
||||
{
|
||||
title: `Doc <draft>`,
|
||||
author: `Bao"yu`,
|
||||
description: `<p style="color: red">Summary & notes</p>`,
|
||||
},
|
||||
"",
|
||||
"",
|
||||
);
|
||||
|
||||
assert.match(html, /<title>Doc <draft><\/title>/);
|
||||
assert.match(html, /meta name="author" content="Bao"yu"/);
|
||||
assert.match(html, /meta name="description" content="<p style="color: red">Summary & notes<\/p>"/);
|
||||
});
|
||||
|
||||
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
|
||||
const rawCss = `
|
||||
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
|
||||
|
||||
@@ -45,19 +45,24 @@ export function loadCodeThemeCss(themeName: string): string {
|
||||
}
|
||||
|
||||
export function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
|
||||
const escapeHtmlAttribute = (value: string) => value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${meta.title}</title>`,
|
||||
` <title>${escapeHtmlAttribute(meta.title)}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
lines.push(` <meta name="author" content="${escapeHtmlAttribute(meta.author)}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
lines.push(` <meta name="description" content="${escapeHtmlAttribute(meta.description)}" />`);
|
||||
}
|
||||
lines.push(` <style>${css}</style>`);
|
||||
if (codeThemeCss) {
|
||||
|
||||
@@ -24,6 +24,10 @@ Soft macaron pastel color blocks on warm cream
|
||||
|
||||
Coral Red (#E8655A) for key data, warnings, and emphasis highlights. Use sparingly — one or two elements per illustration.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Soft pastel macaron color palette. Use block colors as rounded card backgrounds for distinct information sections. Accent coral red sparingly for emphasis on key terms only. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, knowledge sharing, concept explainers, tutorials, tech summaries, onboarding materials
|
||||
|
||||
@@ -22,6 +22,10 @@ Black ink on pure white with sparse semantic accent colors
|
||||
|
||||
Use black ink for all structural elements — lines, text, figures. Accent colors appear only for semantic highlighting: coral red for risks/gaps/problems, muted teal for positive/solution/after-states, dusty lavender for neutral category tags. Total colored pixels must remain under 10% of canvas. Pale gray may back a subtle zone but must never dominate.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Black ink on white canvas. Accent colors for semantic highlighting only — total colored pixels under 10% of canvas. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Compatible With
|
||||
|
||||
- `ink-notes` (primary, default pairing)
|
||||
|
||||
@@ -24,6 +24,10 @@ Vibrant neon colors on dark backgrounds
|
||||
|
||||
Hot Pink (#FF1493) for primary emphasis. High contrast neon-on-dark creates immediate visual impact.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Vibrant neon-on-dark palette. High contrast, immediate visual impact. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Gaming, retro tech, 80s/90s nostalgic content, bold editorial, trend and pop culture
|
||||
|
||||
@@ -23,6 +23,10 @@ Warm earth tones on soft peach, no cool colors
|
||||
|
||||
Warm Orange (#ED8936) for primary emphasis. Warm-only palette — no cool colors (no green, blue, purple). Modern-retro feel.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Warm earth tone palette. Warm-only — no cool colors (no green, blue, purple). Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Product showcases, team introductions, feature grids, brand content, personal growth, lifestyle
|
||||
|
||||
@@ -67,6 +67,17 @@ STYLE (from reference):
|
||||
|
||||
---
|
||||
|
||||
## Color Specification Rules
|
||||
|
||||
Colors in prompts use hex codes for **rendering guidance only** — they tell the model which colors to use, NOT what text to display.
|
||||
|
||||
**⚠️ CRITICAL**: Image generation models sometimes render color names and hex values as visible text labels in the image (e.g., painting "Macaron Blue #A8D8EA" as a label). This must be prevented.
|
||||
|
||||
**Add to ALL prompts that contain a COLORS section**:
|
||||
> Color values (#hex) and color names are rendering guidance only — do NOT display color names, hex codes, or palette labels as visible text in the image.
|
||||
|
||||
---
|
||||
|
||||
## Character Rendering
|
||||
|
||||
When depicting people:
|
||||
|
||||
@@ -21,6 +21,10 @@ Technical, professional, precise
|
||||
- Technical schematics and diagrams
|
||||
- Geometric precision elements
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Architecture, system design, API, technical documentation, engineering, data analysis
|
||||
|
||||
@@ -21,6 +21,10 @@ Cinematic, premium, atmospheric
|
||||
- Silhouettes with backlit edges
|
||||
- Subtle gradient backgrounds
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Entertainment, premium brands, cinematic storytelling, dark mode, gaming, night themes
|
||||
|
||||
@@ -34,6 +34,10 @@ Choose ONE pair based on content mood. The two colors dominate the entire image:
|
||||
- Minimal use of third color (only for small highlights)
|
||||
- High contrast figure-ground relationships
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Movie posters, album covers, concert prints, dramatic announcements, cinematic content, bold branding, editorial covers, artistic campaigns
|
||||
|
||||
@@ -21,6 +21,10 @@ Natural, organic, grounded
|
||||
- Botanical illustrations
|
||||
- Earthy textures and natural patterns
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Nature, wellness, eco, organic, travel, sustainability, outdoor topics, slow living
|
||||
|
||||
@@ -21,6 +21,10 @@ Sophisticated, refined, understated luxury
|
||||
- Refined geometric patterns
|
||||
- Balanced, symmetrical compositions
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Business, professional, thought leadership, luxury, corporate communications
|
||||
|
||||
@@ -21,6 +21,10 @@ Soft macaron pastel color blocks on warm cream
|
||||
- Soft shadows, no hard edges
|
||||
- Gentle gradient transitions between zones
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, knowledge sharing, concept explainers, tutorials, tech summaries, onboarding materials
|
||||
|
||||
@@ -21,6 +21,10 @@ Clean, focused, essential
|
||||
- Single focal point emphasis
|
||||
- Stark contrast between elements
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Zen, focus, essential concepts, pure, simple, minimalist philosophy, clean design
|
||||
|
||||
@@ -21,6 +21,10 @@ Gentle, whimsical, soft
|
||||
- Soft shadows and gentle highlights
|
||||
- Storybook-style elements
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Fantasy, children, gentle content, creative, whimsical, casual, beginner guides
|
||||
|
||||
@@ -25,6 +25,10 @@ Nostalgic, vintage, classic
|
||||
- Pill-shaped clouds, small dots and stars
|
||||
- Classic icons and retro motifs
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
History, vintage, retro, classic, exploration, retrospectives, throwback content, creative proposals, educational
|
||||
|
||||
@@ -21,6 +21,10 @@ Energetic, bold, attention-grabbing
|
||||
- Dramatic lighting effects
|
||||
- High-energy visual compositions
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Product launch, gaming, promotion, event, marketing, announcements, brand showcases
|
||||
|
||||
@@ -21,6 +21,10 @@ Friendly, approachable, human-centered
|
||||
- Hearts, smiling faces, friendly icons
|
||||
- Warm gradient overlays
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Personal growth, lifestyle, education, human stories, emotion, community
|
||||
|
||||
@@ -62,6 +62,7 @@ Visual composition:
|
||||
- Decorative: [palette-specific elements that reinforce content theme]
|
||||
|
||||
Color scheme: [primary, background, accent from palette definition, adjusted by mood]
|
||||
Color constraint: Color values (#hex) and color names are rendering guidance only — do NOT display color names, hex codes, or palette labels as visible text in the image.
|
||||
Rendering notes: [key characteristics from rendering definition — lines, texture, depth, element style]
|
||||
Type notes: [key characteristics from type definition]
|
||||
Palette notes: [key characteristics from palette definition]
|
||||
|
||||
+266
-55
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-diagram
|
||||
description: Generates publication-ready SVG diagrams — flowcharts, sequence/protocol diagrams, structural/architecture diagrams, and illustrative intuition diagrams — by writing real SVG code directly following a cohesive design system. Use whenever the user asks to "draw a flowchart", "draw a sequence diagram", "show the OAuth / TCP / auth protocol", "make an architecture diagram", "explain how X works visually", "画流程图", "画时序图", "画架构图", "画示意图", "画图解", or wants a clean, embeddable vector diagram for articles, WeChat posts, slides, or docs. Output is a single self-contained .svg file that renders correctly in light and dark mode anywhere it is embedded.
|
||||
version: 1.0.0
|
||||
description: Generates publication-ready SVG diagrams — flowcharts, sequence/protocol diagrams, structural/architecture diagrams, and illustrative intuition diagrams — by writing real SVG code directly following a cohesive design system. Supports both single-diagram mode (topic-based) and multi-diagram mode (analyze article content and generate multiple diagrams at identified positions). Use whenever the user asks to "draw a flowchart", "draw a sequence diagram", "show the OAuth / TCP / auth protocol", "make an architecture diagram", "explain how X works visually", "illustrate this article with diagrams", "为文章画图解", "画流程图", "画时序图", "画架构图", "画示意图", "画图解", or wants clean, embeddable vector diagrams for articles, WeChat posts, slides, or docs. Output is one or more self-contained .svg files that render correctly in light and dark mode anywhere they are embedded.
|
||||
version: 1.1.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-diagram
|
||||
@@ -9,12 +9,16 @@ metadata:
|
||||
|
||||
# Diagram Generator
|
||||
|
||||
Write **real SVG code** directly, following a consistent design system, the output is a single `.svg` file that is self-contained (embedded styles, auto dark-mode), editable by humans, scales to any size without quality loss, and embeds cleanly into articles, WeChat posts, slide decks, Notion, and markdown.
|
||||
Write **real SVG code** directly, following a consistent design system, the output is self-contained `.svg` files (embedded styles, auto dark-mode), editable by humans, scales to any size without quality loss, and embeds cleanly into articles, WeChat posts, slide decks, Notion, and markdown.
|
||||
|
||||
When given article or document content, the skill analyzes structure, identifies concepts that benefit from diagramming, and generates multiple diagrams — each with its own type, layout plan, and SVG. When given a single topic, it generates one diagram.
|
||||
|
||||
This is not an image-generation skill — it does not call any LLM image model. Claude writes the SVG node-by-node, doing the layout math by hand so every diagram honors the rules in `references/`.
|
||||
|
||||
## Usage
|
||||
|
||||
### Single-diagram mode
|
||||
|
||||
```bash
|
||||
# Prompt for the topic if no argument given
|
||||
/baoyu-diagram
|
||||
@@ -22,8 +26,8 @@ This is not an image-generation skill — it does not call any LLM image model.
|
||||
# Plain-text description
|
||||
/baoyu-diagram "how JWT authentication works"
|
||||
|
||||
# Path to a markdown file as source content
|
||||
/baoyu-diagram path/to/content.md
|
||||
# Path to a markdown file as source content (single diagram about one topic)
|
||||
/baoyu-diagram path/to/content.md --mode single
|
||||
|
||||
# Force a specific diagram type
|
||||
/baoyu-diagram "transformer attention" --type illustrative
|
||||
@@ -37,13 +41,41 @@ This is not an image-generation skill — it does not call any LLM image model.
|
||||
/baoyu-diagram "build pipeline" --out docs/build-pipeline.svg
|
||||
```
|
||||
|
||||
### Multi-diagram mode
|
||||
|
||||
```bash
|
||||
# Analyze article and generate diagrams at identified positions
|
||||
/baoyu-diagram path/to/article.md
|
||||
|
||||
# With density control
|
||||
/baoyu-diagram path/to/article.md --density balanced
|
||||
/baoyu-diagram path/to/article.md --density per-section --lang zh
|
||||
|
||||
# Force multi mode on pasted content
|
||||
/baoyu-diagram --mode multi
|
||||
```
|
||||
|
||||
### Mode detection
|
||||
|
||||
| Signal | Mode |
|
||||
|--------|------|
|
||||
| File path to `.md` / `.txt` (unless `--mode single`) | **Multi** |
|
||||
| Multi-paragraph pasted content (unless `--mode single`) | **Multi** |
|
||||
| Short quoted topic string (under ~100 chars, no markdown structure) | **Single** |
|
||||
| `--type` given | **Single** (forces) |
|
||||
| `--out` given | **Single** (forces) |
|
||||
| `--mode single` or `--mode multi` | Forced by flag |
|
||||
| Ambiguous | Ask with AskUserQuestion |
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Values |
|
||||
|--------|--------|
|
||||
| `--type` | `flowchart`, `sequence`, `structural`, `illustrative`, `class`, `auto` (default — route on verb) |
|
||||
| `--type` | `flowchart`, `sequence`, `structural`, `illustrative`, `class`, `auto` (default — route on verb). Forces single-diagram mode. |
|
||||
| `--lang` | `en`, `zh`, `ja`, `ko`, ... (default: match the user's language) |
|
||||
| `--out` | Output file path (default: `diagram/{slug}/diagram.svg`) |
|
||||
| `--out` | Output file path (default: `diagram/{slug}/diagram.svg`). Forces single-diagram mode. |
|
||||
| `--density` | `minimal` (1-2), `balanced` (3-5, default), `per-section`, `rich` (6+). Multi-diagram mode only. |
|
||||
| `--mode` | `single`, `multi`, `auto` (default). Override automatic mode detection. |
|
||||
|
||||
## Diagram types
|
||||
|
||||
@@ -108,11 +140,116 @@ Cycles, ERDs, and gantt charts are **out of scope for v1**. For cycles, draw the
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Capture intent
|
||||
### Step 1: Detect mode and capture input
|
||||
|
||||
Read the user's prompt or content file. If the topic is missing, ask for it with AskUserQuestion.
|
||||
Read the user's prompt, content file, or pasted content. Determine the mode:
|
||||
|
||||
**Before routing, extract these five things from the source:**
|
||||
| Signal | Mode |
|
||||
|--------|------|
|
||||
| Short topic string, `--type` given, or `--out` given | **Single** |
|
||||
| File path to `.md` / `.txt`, or multi-paragraph pasted content | **Multi** |
|
||||
| `--mode single` or `--mode multi` | Forced by flag |
|
||||
| Ambiguous (medium-length text, unclear if topic or content) | Ask with AskUserQuestion |
|
||||
|
||||
**Single mode** → jump to Step 5.
|
||||
|
||||
**Multi mode** → continue to Step 2.
|
||||
|
||||
If input is a file path, read the file. If input is pasted content, note it for saving later. If no input at all, ask for it with AskUserQuestion.
|
||||
|
||||
### Step 2: Analyze content (multi-diagram mode)
|
||||
|
||||
Analyze the article or document for diagramming opportunities. For each section or concept cluster, determine:
|
||||
|
||||
| Analysis | Output |
|
||||
|----------|--------|
|
||||
| Content structure | Sections, subsections, key transitions |
|
||||
| Core concepts | 2-8 concepts that benefit from visual explanation |
|
||||
| Diagram positions | Where in the article each diagram belongs (anchored to specific paragraphs or headings) |
|
||||
| Per-position type signal | What verb/need drives each diagram (→ routing table in "Diagram types") |
|
||||
| Per-position complexity | Simple (3-4 nodes) vs. complex (poster/subsystem) |
|
||||
|
||||
**What to diagram:**
|
||||
- Core mechanisms the reader needs to *understand* (→ illustrative)
|
||||
- Multi-step processes described in prose (→ flowchart)
|
||||
- Multi-actor interactions (→ sequence)
|
||||
- Architectural descriptions with containment or hierarchy (→ structural)
|
||||
- Type hierarchies or data models (→ class)
|
||||
- Comparisons between two approaches or systems (→ structural subsystem)
|
||||
|
||||
**What NOT to diagram:**
|
||||
- Simple lists — a bullet list is already visual enough
|
||||
- Concepts already shown in an existing image or figure in the article
|
||||
- Purely emotional or narrative passages with no underlying mechanism
|
||||
- Content that is a single sentence or trivially simple
|
||||
- Decorative filler — every diagram must earn its place with a concrete reader need
|
||||
|
||||
**Output**: A list of N candidate diagram positions, each with: section anchor, tentative type, named elements, and the "After seeing this diagram, the reader understands ___" sentence.
|
||||
|
||||
### Step 3: Confirm settings (multi-diagram mode)
|
||||
|
||||
**ONE AskUserQuestion, max 3 questions. Q1 required. Q2 required unless `--density` given.**
|
||||
|
||||
| Q | When to ask | Options |
|
||||
|---|-------------|---------|
|
||||
| **Q1: Diagram positions** | Always | Show the N candidate positions with tentative types. "I identified N positions for diagrams: [numbered list with section anchor + type + one-line purpose]. Adjust, add, or remove?" |
|
||||
| **Q2: Density** | No `--density` flag | minimal (1-2 core concepts only), balanced (3-5 major concepts, Recommended), per-section (one per section/chapter), rich (6+ comprehensive) |
|
||||
| **Q3: Language** | Article language ≠ user's language or `--lang` | Which language for diagram labels? |
|
||||
|
||||
After confirmation, finalize the list of diagrams to generate. If the user adjusts positions or types, update accordingly.
|
||||
|
||||
### Step 4: Generate outline (multi-diagram mode)
|
||||
|
||||
Save `outline.md` with YAML frontmatter and per-diagram entries:
|
||||
|
||||
```yaml
|
||||
---
|
||||
article: path/to/article.md # or "pasted content"
|
||||
slug: article-topic-slug
|
||||
density: balanced
|
||||
diagram_count: 4
|
||||
language: en
|
||||
---
|
||||
```
|
||||
|
||||
Per-diagram entry format:
|
||||
|
||||
```markdown
|
||||
## Diagram 1
|
||||
**Position**: [section name / paragraph anchor, e.g. "Section 2, after 'Authentication is the first step...'"]
|
||||
**Purpose**: [why this diagram helps the reader]
|
||||
**Type**: [flowchart | sequence | structural | illustrative | class]
|
||||
**Named elements**: [list of actors, components, states, or concepts]
|
||||
**Reader need**: "After seeing this diagram, the reader understands ___"
|
||||
**Slug**: [2-4 kebab-case words]
|
||||
**Filename**: 01-{type}-{slug}/diagram.svg
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
- Each position justified by a concrete reader need (the "After seeing this..." sentence)
|
||||
- Type chosen per the routing table, not arbitrarily
|
||||
- Count matches the confirmed density
|
||||
- If input was pasted content, also save it as `source-{slug}.md` in the output directory
|
||||
|
||||
Save to `diagram/{article-slug}/outline.md`.
|
||||
|
||||
### Step 5: Load shared references
|
||||
|
||||
**Always read**:
|
||||
- `references/design-system.md` — philosophy, typography, color palette, hard rules
|
||||
- `references/svg-template.md` — the `<style>` + `<defs>` boilerplate to copy verbatim
|
||||
- `references/layout-math.md` — text-width estimation, viewBox sizing, arrow routing
|
||||
- `references/pitfalls.md` — the pre-save checklist
|
||||
|
||||
Per-type reference files are loaded inside the generation loop (Step 6b) since each diagram may have a different type.
|
||||
|
||||
### Step 6: Per-diagram generation loop
|
||||
|
||||
For each diagram (single iteration in single mode, N iterations in multi mode):
|
||||
|
||||
#### 6a: Capture intent
|
||||
|
||||
**Single mode**: Read the user's prompt. Extract these five things from the source:
|
||||
|
||||
1. **Named elements** — list every distinct actor, component, service, state, or phase explicitly named. Count them. If the count is 6+, plan multiple diagrams rather than cramming everything into one (see `flowchart.md` → "Planning before you write SVG").
|
||||
|
||||
@@ -125,23 +262,17 @@ Read the user's prompt or content file. If the topic is missing, ask for it with
|
||||
|
||||
3. **What the reader needs** — complete this sentence before routing: *"After seeing this diagram, the reader understands ___."* If you can't finish it, the topic is underspecified — ask.
|
||||
|
||||
4. **Label preview** — for each element name, count the characters. Latin titles >30 chars (CJK >16) will overflow a 180-wide box and need shortening. Draft the abbreviated form now, before layout math, so Step 4 uses real labels.
|
||||
4. **Label preview** — for each element name, count the characters. Latin titles >30 chars (CJK >16) will overflow a 180-wide box and need shortening. Draft the abbreviated form now, before layout math, so Step 6d uses real labels.
|
||||
|
||||
5. **Language** — CJK vs. Latin. Affects text-width multipliers in Step 4 (15 px/char vs. 8 px/char for titles). Mixed content (CJK labels with some Latin terms) counts as CJK.
|
||||
5. **Language** — CJK vs. Latin. Affects text-width multipliers in Step 6d (15 px/char vs. 8 px/char for titles). Mixed content (CJK labels with some Latin terms) counts as CJK.
|
||||
|
||||
### Step 2: Route the diagram type
|
||||
**Multi mode**: Most of this was already done in Steps 2/4. Read the current diagram's outline entry. Refine the named elements and label preview now, for this specific diagram.
|
||||
|
||||
Match the user's phrasing to the table above. If `--type` is given, use it. Otherwise route on the verb. When genuinely ambiguous between flowchart and illustrative, default to **illustrative** — the reader almost always benefits more from intuition than from a list of steps.
|
||||
#### 6b: Route type and load type reference
|
||||
|
||||
Tell the user which type you picked and why, in one sentence. Do not ask for confirmation — the type can always be changed with `--type` on a rerun.
|
||||
**Single mode**: Match the user's phrasing to the routing table in "Diagram types". If `--type` is given, use it. Otherwise route on the verb. When genuinely ambiguous between flowchart and illustrative, default to **illustrative**. Tell the user which type you picked and why, in one sentence.
|
||||
|
||||
### Step 3: Load references
|
||||
|
||||
**Always read**:
|
||||
- `references/design-system.md` — philosophy, typography, color palette, hard rules
|
||||
- `references/svg-template.md` — the `<style>` + `<defs>` boilerplate to copy verbatim
|
||||
- `references/layout-math.md` — text-width estimation, viewBox sizing, arrow routing
|
||||
- `references/pitfalls.md` — the pre-save checklist
|
||||
**Multi mode**: The type was already determined in the outline. Load the matching reference file now.
|
||||
|
||||
**Read the one that matches the type**:
|
||||
- `references/flowchart.md`
|
||||
@@ -154,36 +285,47 @@ Tell the user which type you picked and why, in one sentence. Do not ask for con
|
||||
- `references/glyphs.md` — the shared glyph library, tool card icon set, operator icons, and dark-mode rules
|
||||
|
||||
**Read on demand for diagram type extensions:**
|
||||
- `references/flowchart-poster.md` — when ≥3 poster-mode triggers fire in Step 4a (topic has a short name, named phases, parallel candidates, a loop termination mechanic, overflow annotations, or a footer quote)
|
||||
- `references/flowchart-phase-bands.md` — when the prompt describes a multi-phase sequential operation where each phase contains parallel tools or steps and outcomes propagate between phases ("phase 1/2/3", "attack phases", "phased workflow with tools")
|
||||
- `references/structural-network.md` — when drawing network topology: zone containers, wired/wireless device connectivity, security zones (DMZ / VPC / firewall)
|
||||
- `references/structural-matrix.md` — when drawing a comparison matrix: feature table, ✓/✗ cells, side-by-side "option × attribute" grid
|
||||
- `references/flowchart-poster.md` — when ≥3 poster-mode triggers fire in Step 6d (topic has a short name, named phases, parallel candidates, a loop termination mechanic, overflow annotations, or a footer quote)
|
||||
- `references/flowchart-phase-bands.md` — when the prompt describes a multi-phase sequential operation where each phase contains parallel tools or steps and outcomes propagate between phases
|
||||
- `references/structural-network.md` — when drawing network topology: zone containers, wired/wireless device connectivity, security zones
|
||||
- `references/structural-matrix.md` — when drawing a comparison matrix: feature table, ✓/✗ cells, side-by-side grid
|
||||
|
||||
### Step 3.5: Check the patterns library
|
||||
#### 6c: Check patterns library
|
||||
|
||||
If the user's topic matches a known AI-system pattern (multi-agent research, message bus, shared state, agent with skills, contextual retrieval), there is a pre-cooked starter plan in `references/patterns/`. Scan `references/patterns/README.md` for a pattern name that matches the prompt. If one matches, load that pattern file and use its mermaid reference + baoyu SVG plan as the starting point for Step 4 — the node list, widths, and arrow routing are already drafted, and you only need to adapt labels.
|
||||
If the topic matches a known AI-system pattern, there is a pre-cooked starter plan in `references/patterns/`. Scan `references/patterns/README.md` for a pattern name that matches. If one matches, load that pattern file and use its mermaid reference + baoyu SVG plan as the starting point for Step 6d.
|
||||
|
||||
If nothing matches, skip this step and plan from scratch in Step 4. Do not force a near-miss: two patterns that share a surface name may have genuinely different topologies, and using the wrong pre-plan is worse than planning from scratch.
|
||||
If nothing matches, skip and plan from scratch in Step 6d. Do not force a near-miss.
|
||||
|
||||
### Step 4: Plan on paper first
|
||||
#### 6d: Plan on paper
|
||||
|
||||
Before writing any SVG, draft a short layout plan. Do the math once, correctly, so the SVG comes out right on the first pass.
|
||||
|
||||
**4a. Extract structure from the source** — don't just transcribe the user's bullets into boxes. Read the source looking for these elements. Not every element will be present, but every present element should land in the diagram:
|
||||
**6d-0. Draft the Mermaid sketch first** — write a Mermaid code block that captures the **structural intent** of the diagram: which nodes exist, how they connect, what direction they flow, and any grouping (subgraphs). This is the single source of truth for *what* to draw; everything after it (coordinates, widths, arrows) answers *how*.
|
||||
|
||||
Rules for the Mermaid sketch:
|
||||
- Use the Mermaid dialect that best matches the diagram type: `flowchart TD/LR` for flowcharts, `sequenceDiagram` for sequence, `classDiagram` for class, `flowchart` with subgraphs for structural/illustrative.
|
||||
- Include every node, every edge, every label, and every subgraph/container. If a node won't appear in the Mermaid, it won't appear in the SVG.
|
||||
- Edge labels must match the final SVG labels — write them now, not later.
|
||||
- Keep it concise: the sketch is a structural contract, not a rendering. Mermaid can't express baoyu's visual design (colors, rounded rects, dark mode), so don't try — those come in 6d-ii and 6e.
|
||||
- For patterns that have a Mermaid reference in `references/patterns/`, start from that reference and adapt it to the specific topic.
|
||||
|
||||
Save the Mermaid block in the plan file. When writing SVG in Step 6e, **cross-check every node and edge against this Mermaid sketch** — if the sketch has it, the SVG must have it; if the SVG adds something the sketch doesn't have, update the sketch first.
|
||||
|
||||
**6d-i. Extract structure from the source** — don't just transcribe bullets into boxes. Read the source looking for these elements. Not every element will be present, but every present element should land in the diagram:
|
||||
|
||||
- **Mechanism name** — does the topic have a short, nameable identity (Autoreason, AutoResearch, OAuth, JWT auth, Reflexion loop)? If yes, that's a candidate `.title`.
|
||||
- **Framing question** — does the source contain a "why does this exist" sentence? Usually in the form *"Problem X has no Y, so we built Z to …"*. That's a candidate subtitle.
|
||||
- **Phases** — do the stages naturally cluster into 2–4 named groups (setup / loop body / judgment / convergence)? Each cluster is a candidate `.eyebrow` section.
|
||||
- **Framing question** — does the source contain a "why does this exist" sentence? That's a candidate subtitle.
|
||||
- **Phases** — do the stages naturally cluster into 2–4 named groups? Each cluster is a candidate `.eyebrow` section.
|
||||
- **Anchor inputs** — is there a constant input (the task prompt, a dataset, a knowledge base) that every stage references? That's a candidate anchor box above the main flow.
|
||||
- **Parallel candidates** — at some point, does the process generate N alternatives that are then compared? "Produce A, B, AB and pick the best" is a candidate fan-out + judge pattern. **Watch for the implicit "keep unchanged" candidate** — many iterative methods include "the incumbent wins by default" as one of the options, even when the source doesn't name it explicitly.
|
||||
- **Loop scope + termination** — which boxes are inside a loop that repeats? What is the *specific* termination rule — streak counter, convergence check, iteration cap, quality threshold? That's a candidate left-rail loop bracket + a dedicated termination box.
|
||||
- **Per-box context that won't fit in a subtitle** — "sees task + A + critique / adversarial / fresh context" is too long for a 5-word subtitle but too important to drop. Those are candidate right-column `.anno` annotations.
|
||||
- **Parallel candidates** — at some point, does the process generate N alternatives that are then compared? **Watch for the implicit "keep unchanged" candidate.**
|
||||
- **Loop scope + termination** — which boxes are inside a loop that repeats? What is the *specific* termination rule? That's a candidate left-rail loop bracket + a dedicated termination box.
|
||||
- **Per-box context that won't fit in a subtitle** — those are candidate right-column `.anno` annotations.
|
||||
- **Quotable hook** — does the source end with a test result, a quote, or a memorable framing? That's a candidate footer `.caption`.
|
||||
- **Role categories** — how many *distinct kinds* of operation does the process have (draft / critique / synthesize / judge)? This determines the color budget. Identity is a category, not a sequence.
|
||||
- **Role categories** — how many *distinct kinds* of operation does the process have? This determines the color budget. Identity is a category, not a sequence.
|
||||
|
||||
Write the answers to these in the plan file. If ≥3 of them land, you're building a **poster flowchart** — load `references/flowchart-poster.md` and follow its coordinate budget. Otherwise, it's a simple flowchart and the linear-top-down pattern applies.
|
||||
|
||||
**4b. Draft the layout:**
|
||||
**6d-ii. Draft the layout:**
|
||||
|
||||
1. **List the nodes / regions / shapes** with their full label text (title + optional subtitle).
|
||||
- Simple flowchart: ≤5 nodes.
|
||||
@@ -202,9 +344,13 @@ Write the answers to these in the plan file. If ≥3 of them land, you're buildi
|
||||
5. **Map arrows** and verify none cross an unrelated box. Use L-bends where a straight line would collide. (Sequence messages are always straight horizontal lines — no L-bends. Fan-out candidates converge to a common `ymid` channel just above the judge box.)
|
||||
6. **Compute viewBox height**: `H = max_y + 20` where `max_y` is the bottom of the lowest element. Poster flowcharts routinely reach H=800–950 — don't force them to be compact.
|
||||
|
||||
Save this plan to `diagram/{slug}/plan.md` so iteration runs can re-read it.
|
||||
Save this plan (including the Mermaid sketch from 6d-0):
|
||||
- **Single mode**: `diagram/{slug}/plan.md`
|
||||
- **Multi mode**: `diagram/{article-slug}/NN-{type}-{slug}/plan.md`
|
||||
|
||||
### Step 5: Write the SVG
|
||||
#### 6e: Write the SVG
|
||||
|
||||
**Start from the Mermaid sketch in the plan.** Walk the sketch node-by-node, edge-by-edge, and translate each element into SVG using the coordinates and widths computed in 6d-ii. The Mermaid sketch is the structural checklist — every node and edge in it must appear in the SVG. If you find yourself adding an element that isn't in the sketch, stop and update the sketch first so the plan stays authoritative.
|
||||
|
||||
Emit a single `<svg width="100%" viewBox="0 0 680 H">` element. Copy the `<style>` + `<defs>` block from `svg-template.md` **verbatim** — don't abbreviate or edit the color ramp definitions. Then add visual elements in z-order:
|
||||
|
||||
@@ -220,7 +366,9 @@ Typography rules:
|
||||
- Sentence case everywhere — "User login" not "User Login"
|
||||
- Every `<text>` element gets a class (`t`, `ts`, or `th`) — never hardcode fill colors on text
|
||||
|
||||
### Step 6: Run the pre-save checklist
|
||||
#### 6f: Run the pre-save checklist
|
||||
|
||||
**Mermaid–SVG consistency check** (run before the pitfalls checklist): re-read the Mermaid sketch from the plan. For every node in the sketch, confirm the SVG has a corresponding `<rect>` + `<text>`. For every edge, confirm a `<path>` or `<line>` connects the correct pair. Missing elements are bugs — fix them before continuing.
|
||||
|
||||
Walk through every item in `references/pitfalls.md`. The top failures to catch every time:
|
||||
|
||||
@@ -236,25 +384,25 @@ Walk through every item in `references/pitfalls.md`. The top failures to catch e
|
||||
|
||||
If any item fails, fix the SVG before saving. Don't rationalize past a failure — the checklist exists because these bugs are silent: the SVG is valid but looks wrong when rendered.
|
||||
|
||||
### Step 7: Save output
|
||||
#### 6g: Save and report progress
|
||||
|
||||
If `--out` is given, save the SVG there and skip the scaffolded directory. Otherwise use the default layout:
|
||||
Save the SVG and plan:
|
||||
- **Single mode**: `diagram/{slug}/plan.md` + `diagram.svg`
|
||||
- **Multi mode**: `diagram/{article-slug}/NN-{type}-{slug}/plan.md` + `diagram.svg`
|
||||
|
||||
```
|
||||
diagram/{topic-slug}/
|
||||
├── source-{slug}.md # optional: user's input content if provided
|
||||
├── plan.md # layout sketch from Step 4
|
||||
└── diagram.svg # final output
|
||||
```
|
||||
**Backup rule**: if `diagram.svg` already exists at the target path, rename the existing one to `diagram-backup-YYYYMMDD-HHMMSS.svg` before writing the new file — never overwrite prior work silently.
|
||||
|
||||
- **Slug**: 2–4 kebab-case words derived from the topic.
|
||||
- **Backup rule**: if `diagram.svg` already exists at the target path, rename the existing one to `diagram-backup-YYYYMMDD-HHMMSS.svg` before writing the new file — never overwrite prior work silently.
|
||||
- **Plan**: always save `plan.md` from Step 4 beside the SVG so the next iteration can re-read it.
|
||||
- **Source**: if the user pasted source content, save it as `source-{slug}.md` in the same directory.
|
||||
**Multi mode progress**: after each diagram, report progress: "Generated 2/4: 02-illustrative-jwt-token-structure".
|
||||
|
||||
### Step 7: Finalize (multi-diagram mode)
|
||||
|
||||
If input was a file path, insert `` at each identified position in the article. Compute the relative path based on the article location vs. the diagram output directory.
|
||||
|
||||
If input was pasted content, skip insertion — the diagrams are generated and the outline records positions, but there's no source file to modify.
|
||||
|
||||
### Step 8: Report
|
||||
|
||||
Tell the user in 4-6 lines:
|
||||
**Single mode** — tell the user in 4-6 lines:
|
||||
- Diagram type picked (and one-sentence why)
|
||||
- Node count / complexity
|
||||
- viewBox dimensions
|
||||
@@ -262,10 +410,73 @@ Tell the user in 4-6 lines:
|
||||
- Output file path
|
||||
- One suggestion for how to preview it (e.g., "Open in Chrome for light/dark check")
|
||||
|
||||
**Multi mode**:
|
||||
|
||||
```
|
||||
Article Diagram Generation Complete!
|
||||
|
||||
Article: [path or "pasted content"]
|
||||
Density: [level] | Language: [lang]
|
||||
Diagrams: X/N generated
|
||||
|
||||
Positions:
|
||||
- 01-sequence-jwt-auth-flow → After "Authentication is the first step..."
|
||||
- 02-illustrative-jwt-token-structure → After "The token structure..."
|
||||
- 03-flowchart-token-refresh → After "When the token expires..."
|
||||
- 04-structural-microservice-auth → After "In a microservices architecture..."
|
||||
|
||||
Output: diagram/{article-slug}/
|
||||
Preview: Open any .svg in Chrome for light/dark check
|
||||
```
|
||||
|
||||
## Output structure
|
||||
|
||||
### Single-diagram mode
|
||||
|
||||
```
|
||||
diagram/{topic-slug}/
|
||||
├── source-{slug}.md # optional: user's input content if provided
|
||||
├── plan.md # layout sketch from Step 6d
|
||||
└── diagram.svg # final output
|
||||
```
|
||||
|
||||
### Multi-diagram mode
|
||||
|
||||
```
|
||||
diagram/{article-slug}/
|
||||
├── source-{slug}.md # saved input content (file copy or pasted content)
|
||||
├── outline.md # frontmatter + all diagram entries from Step 4
|
||||
├── 01-{type}-{slug}/
|
||||
│ ├── plan.md # layout sketch for this diagram
|
||||
│ └── diagram.svg # final SVG
|
||||
├── 02-{type}-{slug}/
|
||||
│ ├── plan.md
|
||||
│ └── diagram.svg
|
||||
└── 03-{type}-{slug}/
|
||||
├── plan.md
|
||||
└── diagram.svg
|
||||
```
|
||||
|
||||
- **Slug**: 2–4 kebab-case words derived from the topic or concept.
|
||||
- **Backup rule**: if `diagram.svg` already exists at the target path, rename the existing one to `diagram-backup-YYYYMMDD-HHMMSS.svg` before writing the new file.
|
||||
- **Plan**: always save `plan.md` beside the SVG so the next iteration can re-read it.
|
||||
- **Source**: if the user pasted source content, save it as `source-{slug}.md` in the output directory.
|
||||
- **Numbering**: NN prefix (01, 02, ...) matches the outline order, which matches article position order.
|
||||
- **Outline**: in multi mode, always save `outline.md` from Step 4 so the generation can be resumed or individual diagrams can be regenerated.
|
||||
|
||||
## Modification
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Regenerate one diagram** (multi mode) | Re-read `outline.md` → find the entry → re-run Step 6 for that diagram only → update the SVG |
|
||||
| **Add a diagram** (multi mode) | Identify position → add entry to `outline.md` → run Step 6 for the new entry → insert into article |
|
||||
| **Remove a diagram** (multi mode) | Delete the `NN-{type}-{slug}/` directory → remove entry from `outline.md` → remove `![...]` from article |
|
||||
| **Change type** (single or multi) | Update the outline entry or re-run with `--type` → regenerate |
|
||||
|
||||
## Core principles
|
||||
|
||||
- **Draw the mechanism, not a diagram about the mechanism** (illustrative). **Draw the sequence, not the architecture** (flowchart). **Draw the containment, not the flow** (structural). **Draw the conversation, not the steps** (sequence). Picking the wrong type is the single biggest failure mode — more harmful than any layout bug.
|
||||
- **One design system, always.** No `--style` flag, no alternate themes, no per-topic visual variants. The cohesive look across every diagram is the product — if a reader sees two baoyu diagrams in different articles, they should feel they came from the same hand. Any request to "use a different style" is a request to break this principle; push back and ask what the underlying need is instead.
|
||||
- **One design system, always.** No `--style` flag, no alternate themes, no per-topic visual variants. The cohesive look across every diagram is the product — if a reader sees two baoyu diagrams in different articles, they should feel they came from the same hand. Any request to "use a different style" is a request to break this principle; push back and ask what the underlying need is instead. In multi-diagram mode, all diagrams in a run share the same design system — no per-diagram style overrides.
|
||||
- **Self-contained output.** Every SVG carries its own styles and dark-mode rules. The reader should never need to edit anything after pasting it into their article.
|
||||
- **Math before markup.** SVG has no auto-layout. Every coordinate is hand-computed. A diagram that "almost fits" has a bug — fix the math, don't nudge pixels.
|
||||
- **Color encodes meaning, not position.** Five steps in a flowchart are not five colors. All five are gray unless one specific step deserves emphasis — in which case it gets the accent color.
|
||||
|
||||
@@ -22,7 +22,7 @@ Vibrant neon colors on dark background. High-energy, futuristic, eye-catching.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Vibrant neon color palette on dark background. Colors should glow against the dark base. High contrast, futuristic feel. Use neon sparingly — too many glowing elements become chaotic. Let dark background breathe.
|
||||
Vibrant neon color palette on dark background. Colors should glow against the dark base. High contrast, futuristic feel. Use neon sparingly — too many glowing elements become chaotic. Let dark background breathe. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Warm earth tones on soft peach background. Cozy, inviting, no cool colors.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Warm-only color palette, no cool colors (no blue, green, purple). Earth tones throughout. Evokes comfort, warmth, and trust. All colors should feel like autumn sunlight.
|
||||
Warm-only color palette, no cool colors (no blue, green, purple). Earth tones throughout. Evokes comfort, warmth, and trust. All colors should feel like autumn sunlight. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md") { "user" }
|
||||
|
||||
Legacy compatibility: if `.baoyu-skills/baoyu-image-gen/EXTEND.md` exists and the new path does not, runtime renames it to `baoyu-imagine`. If both files exist, runtime leaves them unchanged and uses the new path.
|
||||
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | OpenAI image API dialect | Default models | Batch worker cap | Provider-specific batch limits
|
||||
|
||||
Schema: `references/config/preferences-schema.md`
|
||||
|
||||
@@ -76,7 +76,7 @@ ${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, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate supported families, 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)
|
||||
@@ -118,11 +118,14 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A girl stands by the library window
|
||||
# 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)
|
||||
# Replicate (default: google/nano-banana-2)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
# Replicate with specific model
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||
# Replicate Seedream 4.5
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic portrait" --image out.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
|
||||
|
||||
# Replicate Wan 2.7 Image Pro
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A concept frame" --image out.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
|
||||
|
||||
# Batch mode with saved prompt files
|
||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json
|
||||
@@ -142,7 +145,7 @@ ${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --json
|
||||
"promptFiles": ["prompts/hero.md"],
|
||||
"image": "out/hero.png",
|
||||
"provider": "replicate",
|
||||
"model": "google/nano-banana-pro",
|
||||
"model": "google/nano-banana-2",
|
||||
"ar": "16:9",
|
||||
"quality": "2k"
|
||||
},
|
||||
@@ -173,8 +176,9 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `--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, 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 |
|
||||
| `--imageApiDialect openai-native\|ratio-metadata` | OpenAI-compatible image API dialect. Use `ratio-metadata` when the endpoint is OpenAI-compatible but expects aspect-ratio `size` plus `metadata.resolution` instead of pixel `size` |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate supported families, 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. Replicate currently supports only `--n 1` because this path saves exactly one output image |
|
||||
| `--json` | JSON output |
|
||||
|
||||
## Environment Variables
|
||||
@@ -202,10 +206,11 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `ZAI_IMAGE_MODEL` | Z.AI model override (default: `glm-image`) |
|
||||
| `BIGMODEL_IMAGE_MODEL` | Backward-compatible alias for Z.AI model override |
|
||||
| `MINIMAX_IMAGE_MODEL` | MiniMax model override (default: `image-01`) |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-2) |
|
||||
| `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 |
|
||||
| `OPENAI_IMAGE_API_DIALECT` | OpenAI-compatible image API dialect override (`openai-native` or `ratio-metadata`) |
|
||||
| `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`) |
|
||||
@@ -239,6 +244,22 @@ For Azure, `--model` / `default_model.azure` should be the Azure deployment name
|
||||
|
||||
**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.
|
||||
|
||||
### OpenAI-Compatible Gateway Dialects
|
||||
|
||||
`provider=openai` means the auth and routing entrypoint is OpenAI-compatible. It does **not** guarantee that the upstream image API uses OpenAI native image-request semantics.
|
||||
|
||||
Use `default_image_api_dialect` in `EXTEND.md`, `OPENAI_IMAGE_API_DIALECT`, or `--imageApiDialect` when the endpoint expects a different wire format:
|
||||
|
||||
- `openai-native`: Sends pixel `size` such as `1536x1024` and native OpenAI quality fields when supported
|
||||
- `ratio-metadata`: Sends aspect-ratio `size` such as `16:9` and maps quality/size intent into `metadata.resolution` (`1K|2K|4K`) plus `metadata.orientation`
|
||||
|
||||
Recommended use:
|
||||
|
||||
- OpenAI native Images API or strict clones: keep `openai-native`
|
||||
- OpenAI-compatible gateways in front of Gemini or similar models: try `ratio-metadata`
|
||||
|
||||
Current limitation: `ratio-metadata` only applies to text-to-image generation. Reference-image edit flows still require `openai-native` or another provider with first-class edit support.
|
||||
|
||||
**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`
|
||||
@@ -360,10 +381,33 @@ Notes:
|
||||
|
||||
### Replicate Models
|
||||
|
||||
Supported model formats:
|
||||
Replicate support in `baoyu-imagine` is intentionally scoped to the model families that the tool can validate locally and save without dropping outputs:
|
||||
|
||||
- `owner/name` (recommended for official models), e.g. `google/nano-banana-pro`
|
||||
- `owner/name:version` (community models by version), e.g. `stability-ai/sdxl:<version>`
|
||||
- `google/nano-banana*` (default: `google/nano-banana-2`)
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `aspect_ratio`, `resolution`, and `output_format`
|
||||
- `--size <WxH>` is accepted only as a shorthand for a documented aspect ratio plus `1K` / `2K`
|
||||
- `bytedance/seedream-4.5`
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `size`, `aspect_ratio`, and `image_input`
|
||||
- Local validation blocks unsupported `1K` requests before the API call
|
||||
- `bytedance/seedream-5-lite`
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `size`, `aspect_ratio`, and `image_input`
|
||||
- Local validation currently accepts `2K` / `3K` only
|
||||
- `wan-video/wan-2.7-image`
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `size` and `images`
|
||||
- Max output size is 2K
|
||||
- `wan-video/wan-2.7-image-pro`
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `size` and `images`
|
||||
- 4K is allowed only for text-to-image; local validation blocks `4K + --ref`
|
||||
|
||||
Guardrails:
|
||||
|
||||
- Replicate currently supports only single-output save semantics in this tool. Keep `--n 1`.
|
||||
- If a Replicate model is outside the compatibility list above, `baoyu-imagine` only treats it as prompt-only and rejects advanced local options instead of guessing a nano-banana-style schema.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -398,7 +442,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||
- Google multimodal: uses `imageConfig.aspectRatio`
|
||||
- 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`
|
||||
- Replicate: behavior is model-family-specific. `google/nano-banana*` uses `aspect_ratio`; `bytedance/seedream-*` uses documented Replicate aspect ratios; Wan 2.7 maps `--ar` to a concrete `size`
|
||||
- MiniMax: sends official `aspect_ratio` values directly; if `--size <WxH>` is given without `--ar`, `width` / `height` are sent for `image-01`
|
||||
|
||||
## Generation Mode
|
||||
|
||||
@@ -58,7 +58,7 @@ options:
|
||||
- label: "MiniMax"
|
||||
description: "MiniMax image generation with subject-reference character workflows"
|
||||
- label: "Replicate"
|
||||
description: "Community models - nano-banana-pro, flexible model selection"
|
||||
description: "Curated Replicate image families - nano-banana-2, Seedream, and Wan image models"
|
||||
```
|
||||
|
||||
### Question 2: Default Google Model
|
||||
@@ -175,6 +175,7 @@ default_provider: [selected provider or null]
|
||||
default_quality: [selected quality]
|
||||
default_aspect_ratio: null
|
||||
default_image_size: null
|
||||
default_image_api_dialect: null
|
||||
default_model:
|
||||
google: [selected google model or null]
|
||||
openai: null
|
||||
@@ -187,6 +188,8 @@ default_model:
|
||||
---
|
||||
```
|
||||
|
||||
If the user selects `OpenAI` but says their endpoint is only OpenAI-compatible and fronts another image model family, save `default_image_api_dialect: ratio-metadata` when they explicitly confirm the gateway expects aspect-ratio `size` plus metadata-based resolution. Otherwise leave it `null` / `openai-native`.
|
||||
|
||||
## Flow 2: EXTEND.md Exists, Model Null
|
||||
|
||||
When EXTEND.md exists but `default_model.[current_provider]` is null, ask ONLY the model question for the current provider.
|
||||
@@ -298,10 +301,14 @@ Notes for Z.AI setup:
|
||||
header: "Replicate Model"
|
||||
question: "Choose a default Replicate image generation model?"
|
||||
options:
|
||||
- label: "google/nano-banana-pro (Recommended)"
|
||||
description: "Google's fast image model on Replicate"
|
||||
- label: "google/nano-banana"
|
||||
description: "Google's base image model on Replicate"
|
||||
- label: "google/nano-banana-2 (Recommended)"
|
||||
description: "Current default for general Replicate image generation in baoyu-imagine"
|
||||
- label: "bytedance/seedream-4.5"
|
||||
description: "Replicate Seedream 4.5 with validated local size/ref guardrails"
|
||||
- label: "bytedance/seedream-5-lite"
|
||||
description: "Replicate Seedream 5 Lite with validated local size/ref guardrails"
|
||||
- label: "wan-video/wan-2.7-image-pro"
|
||||
description: "Replicate Wan 2.7 Image Pro with 4K text-to-image support"
|
||||
```
|
||||
|
||||
### MiniMax Model Selection
|
||||
|
||||
@@ -19,6 +19,8 @@ default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null
|
||||
|
||||
default_image_size: null # 1K|2K|4K|null (Google/OpenRouter, overrides quality)
|
||||
|
||||
default_image_api_dialect: null # openai-native|ratio-metadata|null (OpenAI-compatible gateways; null = use env/default)
|
||||
|
||||
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"
|
||||
@@ -27,7 +29,7 @@ default_model:
|
||||
dashscope: null # e.g., "qwen-image-2.0-pro"
|
||||
zai: null # e.g., "glm-image"
|
||||
minimax: null # e.g., "image-01"
|
||||
replicate: null # e.g., "google/nano-banana-pro"
|
||||
replicate: null # e.g., "google/nano-banana-2"
|
||||
|
||||
batch:
|
||||
max_workers: 10
|
||||
@@ -68,6 +70,7 @@ batch:
|
||||
| `default_quality` | string\|null | null | Default quality (null = 2k) |
|
||||
| `default_aspect_ratio` | string\|null | null | Default aspect ratio |
|
||||
| `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) |
|
||||
| `default_image_api_dialect` | string\|null | null | OpenAI-compatible image dialect (`openai-native` or `ratio-metadata`) |
|
||||
| `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 |
|
||||
@@ -88,6 +91,7 @@ batch:
|
||||
version: 1
|
||||
default_provider: google
|
||||
default_quality: 2k
|
||||
default_image_api_dialect: null
|
||||
---
|
||||
```
|
||||
|
||||
@@ -99,6 +103,7 @@ default_provider: google
|
||||
default_quality: 2k
|
||||
default_aspect_ratio: "16:9"
|
||||
default_image_size: 2K
|
||||
default_image_api_dialect: null
|
||||
default_model:
|
||||
google: "gemini-3-pro-image-preview"
|
||||
openai: "gpt-image-1.5"
|
||||
@@ -107,7 +112,7 @@ default_model:
|
||||
dashscope: "qwen-image-2.0-pro"
|
||||
zai: "glm-image"
|
||||
minimax: "image-01"
|
||||
replicate: "google/nano-banana-pro"
|
||||
replicate: "google/nano-banana-2"
|
||||
batch:
|
||||
max_workers: 10
|
||||
provider_limits:
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
mergeConfig,
|
||||
normalizeOutputImagePath,
|
||||
parseArgs,
|
||||
parseOpenAIImageApiDialect,
|
||||
parseSimpleYaml,
|
||||
} from "./main.ts";
|
||||
|
||||
@@ -28,9 +29,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
@@ -83,6 +87,8 @@ test("parseArgs parses the main baoyu-imagine CLI flags", () => {
|
||||
"2k",
|
||||
"--imageSize",
|
||||
"4k",
|
||||
"--imageApiDialect",
|
||||
"ratio-metadata",
|
||||
"--ref",
|
||||
"ref/one.png",
|
||||
"ref/two.jpg",
|
||||
@@ -97,7 +103,10 @@ test("parseArgs parses the main baoyu-imagine CLI flags", () => {
|
||||
assert.equal(args.imagePath, "out/hero");
|
||||
assert.equal(args.provider, "zai");
|
||||
assert.equal(args.quality, "2k");
|
||||
assert.equal(args.aspectRatioSource, null);
|
||||
assert.equal(args.imageSize, "4K");
|
||||
assert.equal(args.imageSizeSource, "cli");
|
||||
assert.equal(args.imageApiDialect, "ratio-metadata");
|
||||
assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]);
|
||||
assert.equal(args.n, 3);
|
||||
assert.equal(args.jobs, 5);
|
||||
@@ -121,6 +130,7 @@ default_provider: openrouter
|
||||
default_quality: normal
|
||||
default_aspect_ratio: '16:9'
|
||||
default_image_size: 2K
|
||||
default_image_api_dialect: ratio-metadata
|
||||
default_model:
|
||||
google: gemini-3-pro-image-preview
|
||||
openai: gpt-image-1.5
|
||||
@@ -153,6 +163,7 @@ batch:
|
||||
assert.equal(config.default_quality, "normal");
|
||||
assert.equal(config.default_aspect_ratio, "16:9");
|
||||
assert.equal(config.default_image_size, "2K");
|
||||
assert.equal(config.default_image_api_dialect, "ratio-metadata");
|
||||
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?.zai, "glm-image");
|
||||
@@ -248,13 +259,48 @@ test("mergeConfig only fills values missing from CLI args", () => {
|
||||
default_quality: "2k",
|
||||
default_aspect_ratio: "3:2",
|
||||
default_image_size: "2K",
|
||||
default_image_api_dialect: "ratio-metadata",
|
||||
} satisfies Partial<ExtendConfig>,
|
||||
);
|
||||
|
||||
assert.equal(merged.provider, "openai");
|
||||
assert.equal(merged.quality, "2k");
|
||||
assert.equal(merged.aspectRatio, "3:2");
|
||||
assert.equal(merged.aspectRatioSource, "config");
|
||||
assert.equal(merged.imageSize, "4K");
|
||||
assert.equal(merged.imageSizeSource, "cli");
|
||||
assert.equal(merged.imageApiDialect, "ratio-metadata");
|
||||
});
|
||||
|
||||
test("mergeConfig tags inherited imageSize defaults so providers can ignore incompatible config", () => {
|
||||
const merged = mergeConfig(
|
||||
makeArgs(),
|
||||
{
|
||||
default_image_size: "2K",
|
||||
} satisfies Partial<ExtendConfig>,
|
||||
);
|
||||
|
||||
assert.equal(merged.imageSize, "2K");
|
||||
assert.equal(merged.imageSizeSource, "config");
|
||||
});
|
||||
|
||||
test("mergeConfig falls back to OPENAI_IMAGE_API_DIALECT when CLI and EXTEND are unset", (t) => {
|
||||
useEnv(t, {
|
||||
OPENAI_IMAGE_API_DIALECT: "ratio-metadata",
|
||||
});
|
||||
|
||||
const merged = mergeConfig(makeArgs(), {});
|
||||
assert.equal(merged.imageApiDialect, "ratio-metadata");
|
||||
});
|
||||
|
||||
test("parseOpenAIImageApiDialect validates supported values", () => {
|
||||
assert.equal(parseOpenAIImageApiDialect("openai-native"), "openai-native");
|
||||
assert.equal(parseOpenAIImageApiDialect("ratio-metadata"), "ratio-metadata");
|
||||
assert.equal(parseOpenAIImageApiDialect(null), null);
|
||||
assert.throws(
|
||||
() => parseOpenAIImageApiDialect("gateway-magic"),
|
||||
/Invalid OpenAI image API dialect/,
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => {
|
||||
@@ -474,6 +520,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
makeArgs({
|
||||
provider: "replicate",
|
||||
quality: "2k",
|
||||
imageApiDialect: "ratio-metadata",
|
||||
json: true,
|
||||
}),
|
||||
loaded.tasks[0]!,
|
||||
@@ -490,6 +537,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
assert.equal(taskArgs.provider, "replicate");
|
||||
assert.equal(taskArgs.aspectRatio, "16:9");
|
||||
assert.equal(taskArgs.quality, "2k");
|
||||
assert.equal(taskArgs.imageApiDialect, "ratio-metadata");
|
||||
assert.equal(taskArgs.json, true);
|
||||
});
|
||||
|
||||
@@ -503,5 +551,11 @@ test("path normalization, worker count, and retry classification follow expected
|
||||
assert.equal(getWorkerCount(5, 0, 4), 1);
|
||||
|
||||
assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false);
|
||||
assert.equal(
|
||||
isRetryableGenerationError(
|
||||
new Error("Replicate returned 2 outputs, but baoyu-imagine currently supports saving exactly one image per request."),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isRetryableGenerationError(new Error("socket hang up")), true);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
BatchTaskInput,
|
||||
CliArgs,
|
||||
ExtendConfig,
|
||||
OpenAIImageApiDialect,
|
||||
Provider,
|
||||
} from "./types";
|
||||
|
||||
@@ -83,8 +84,9 @@ Options:
|
||||
--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, OpenAI, Azure, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
--n <count> Number of images for the current task (default: 1)
|
||||
--imageApiDialect <id> OpenAI-compatible image dialect: openai-native|ratio-metadata
|
||||
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
--n <count> Number of images for the current task (default: 1; Replicate currently requires 1)
|
||||
--json JSON output
|
||||
-h, --help Show help
|
||||
|
||||
@@ -97,7 +99,7 @@ Batch file format:
|
||||
"promptFiles": ["prompts/hero.md"],
|
||||
"image": "out/hero.png",
|
||||
"provider": "replicate",
|
||||
"model": "google/nano-banana-pro",
|
||||
"model": "google/nano-banana-2",
|
||||
"ar": "16:9"
|
||||
}
|
||||
]
|
||||
@@ -107,6 +109,7 @@ Behavior:
|
||||
- Batch mode automatically runs in parallel when pending tasks >= 2
|
||||
- Each image retries automatically up to 3 attempts
|
||||
- Batch summary reports success count, failure count, and per-image errors
|
||||
- Replicate currently supports single-image save semantics only; --n must stay at 1
|
||||
|
||||
Environment variables:
|
||||
OPENAI_API_KEY OpenAI API key
|
||||
@@ -128,10 +131,11 @@ Environment variables:
|
||||
ZAI_IMAGE_MODEL Default Z.AI model (glm-image)
|
||||
BIGMODEL_IMAGE_MODEL Backward-compatible alias for Z.AI model (glm-image)
|
||||
MINIMAX_IMAGE_MODEL Default MiniMax model (image-01)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-2)
|
||||
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
||||
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
||||
OPENAI_BASE_URL Custom OpenAI endpoint
|
||||
OPENAI_IMAGE_API_DIALECT OpenAI-compatible image dialect (openai-native|ratio-metadata)
|
||||
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
|
||||
OPENROUTER_BASE_URL Custom OpenRouter endpoint
|
||||
OPENROUTER_HTTP_REFERER Optional app URL for OpenRouter attribution
|
||||
@@ -164,9 +168,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
@@ -270,6 +277,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error("Missing value for --ar");
|
||||
out.aspectRatio = v;
|
||||
out.aspectRatioSource = "cli";
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -291,6 +299,16 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
const v = argv[++i]?.toUpperCase();
|
||||
if (v !== "1K" && v !== "2K" && v !== "4K") throw new Error(`Invalid imageSize: ${v}`);
|
||||
out.imageSize = v;
|
||||
out.imageSizeSource = "cli";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === "--imageApiDialect") {
|
||||
const v = argv[++i];
|
||||
if (v !== "openai-native" && v !== "ratio-metadata") {
|
||||
throw new Error(`Invalid imageApiDialect: ${v}`);
|
||||
}
|
||||
out.imageApiDialect = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -397,6 +415,9 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
||||
} else if (key === "default_image_size") {
|
||||
config.default_image_size = value === "null" ? null : value as "1K" | "2K" | "4K";
|
||||
} else if (key === "default_image_api_dialect") {
|
||||
config.default_image_api_dialect =
|
||||
value === "null" ? null : parseOpenAIImageApiDialect(value);
|
||||
} else if (key === "default_model") {
|
||||
config.default_model = {
|
||||
google: null,
|
||||
@@ -482,6 +503,15 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
return config;
|
||||
}
|
||||
|
||||
export function parseOpenAIImageApiDialect(
|
||||
value: string | undefined | null
|
||||
): OpenAIImageApiDialect | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.replace(/['"]/g, "").trim();
|
||||
if (normalized === "openai-native" || normalized === "ratio-metadata") return normalized;
|
||||
throw new Error(`Invalid OpenAI image API dialect: ${value}`);
|
||||
}
|
||||
|
||||
type ExtendConfigPathPair = {
|
||||
current: string;
|
||||
legacy: string;
|
||||
@@ -541,12 +571,25 @@ export async function loadExtendConfig(
|
||||
}
|
||||
|
||||
export function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
const aspectRatio = args.aspectRatio ?? extend.default_aspect_ratio ?? null;
|
||||
const imageSize = args.imageSize ?? extend.default_image_size ?? null;
|
||||
const imageApiDialect =
|
||||
args.imageApiDialect ??
|
||||
extend.default_image_api_dialect ??
|
||||
parseOpenAIImageApiDialect(process.env.OPENAI_IMAGE_API_DIALECT);
|
||||
return {
|
||||
...args,
|
||||
provider: args.provider ?? extend.default_provider ?? null,
|
||||
quality: args.quality ?? extend.default_quality ?? null,
|
||||
aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
|
||||
imageSize: args.imageSize ?? extend.default_image_size ?? null,
|
||||
aspectRatio,
|
||||
aspectRatioSource:
|
||||
args.aspectRatioSource ??
|
||||
(args.aspectRatio !== null ? "cli" : (aspectRatio !== null ? "config" : null)),
|
||||
imageSize,
|
||||
imageSizeSource:
|
||||
args.imageSizeSource ??
|
||||
(args.imageSize !== null ? "cli" : (imageSize !== null ? "config" : null)),
|
||||
imageApiDialect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -759,6 +802,7 @@ export function isRetryableGenerationError(error: unknown): boolean {
|
||||
"API error (403)",
|
||||
"API error (404)",
|
||||
"temporarily disabled",
|
||||
"supports saving exactly one image",
|
||||
];
|
||||
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
||||
}
|
||||
@@ -872,9 +916,12 @@ export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir
|
||||
provider: task.provider ?? baseArgs.provider ?? null,
|
||||
model: task.model ?? baseArgs.model ?? null,
|
||||
aspectRatio: task.ar ?? baseArgs.aspectRatio ?? null,
|
||||
aspectRatioSource: task.ar != null ? "task" : (baseArgs.aspectRatioSource ?? null),
|
||||
size: task.size ?? baseArgs.size ?? null,
|
||||
quality: task.quality ?? baseArgs.quality ?? null,
|
||||
imageSize: task.imageSize ?? baseArgs.imageSize ?? null,
|
||||
imageSizeSource: task.imageSize != null ? "task" : (baseArgs.imageSizeSource ?? null),
|
||||
imageApiDialect: task.imageApiDialect ?? baseArgs.imageApiDialect ?? null,
|
||||
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],
|
||||
n: task.n ?? baseArgs.n,
|
||||
batchFile: null,
|
||||
|
||||
@@ -48,6 +48,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -2,9 +2,16 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildOpenAIGenerationsBody,
|
||||
extractImageFromResponse,
|
||||
getOpenAIAspectRatio,
|
||||
getOpenAIImageApiDialect,
|
||||
getOpenAIResolution,
|
||||
getMimeType,
|
||||
getOpenAISize,
|
||||
getOrientationFromAspectRatio,
|
||||
inferAspectRatioFromSize,
|
||||
inferResolutionFromSize,
|
||||
parseAspectRatio,
|
||||
} from "./openai.ts";
|
||||
|
||||
@@ -18,6 +25,69 @@ test("OpenAI aspect-ratio parsing and size selection match model families", () =
|
||||
assert.equal(getOpenAISize("dall-e-2", "16:9", "2k"), "1024x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "16:9", "2k"), "1536x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "4:3", "2k"), "1024x1024");
|
||||
assert.equal(inferAspectRatioFromSize("1536x1024"), "3:2");
|
||||
assert.equal(inferResolutionFromSize("1536x1024"), "2K");
|
||||
assert.equal(getOpenAIAspectRatio({ aspectRatio: null, size: "2048x1152" }), "16:9");
|
||||
assert.equal(getOpenAIResolution({ imageSize: null, size: "2048x1152", quality: "normal" }), "2K");
|
||||
assert.equal(getOrientationFromAspectRatio("16:9"), "landscape");
|
||||
assert.equal(getOrientationFromAspectRatio("9:16"), "portrait");
|
||||
assert.equal(getOrientationFromAspectRatio("1:1"), null);
|
||||
assert.equal(getOpenAIImageApiDialect({ imageApiDialect: null }), "openai-native");
|
||||
});
|
||||
|
||||
test("OpenAI generations body switches between native and ratio-metadata dialects", () => {
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a skyline", "gpt-image-1.5", {
|
||||
aspectRatio: "16:9",
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
}),
|
||||
{
|
||||
model: "gpt-image-1.5",
|
||||
prompt: "Draw a skyline",
|
||||
size: "1536x1024",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a skyline", "gemini-3-pro-image-preview", {
|
||||
aspectRatio: "16:9",
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageApiDialect: "ratio-metadata",
|
||||
}),
|
||||
{
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "Draw a skyline",
|
||||
size: "16:9",
|
||||
metadata: {
|
||||
resolution: "2K",
|
||||
orientation: "landscape",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a portrait", "gemini-3-pro-image-preview", {
|
||||
aspectRatio: null,
|
||||
size: "1152x2048",
|
||||
quality: "normal",
|
||||
imageSize: null,
|
||||
imageApiDialect: "ratio-metadata",
|
||||
}),
|
||||
{
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "Draw a portrait",
|
||||
size: "9:16",
|
||||
metadata: {
|
||||
resolution: "2K",
|
||||
orientation: "portrait",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI mime-type detection covers supported reference image extensions", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
import type { CliArgs, OpenAIImageApiDialect } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||
@@ -23,6 +23,8 @@ type SizeMapping = {
|
||||
portrait: string;
|
||||
};
|
||||
|
||||
type OpenAIGenerationsBody = Record<string, unknown>;
|
||||
|
||||
export function getOpenAISize(
|
||||
model: string,
|
||||
ar: string | null,
|
||||
@@ -60,6 +62,114 @@ export function getOpenAISize(
|
||||
return sizes.square;
|
||||
}
|
||||
|
||||
function parsePixelSize(value: string): { width: number; height: number } | null {
|
||||
const match = value.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 gcd(a: number, b: number): number {
|
||||
let x = Math.abs(a);
|
||||
let y = Math.abs(b);
|
||||
while (y !== 0) {
|
||||
const next = x % y;
|
||||
x = y;
|
||||
y = next;
|
||||
}
|
||||
return x || 1;
|
||||
}
|
||||
|
||||
export function getOpenAIImageApiDialect(args: Pick<CliArgs, "imageApiDialect">): OpenAIImageApiDialect {
|
||||
return args.imageApiDialect ?? "openai-native";
|
||||
}
|
||||
|
||||
export function inferAspectRatioFromSize(size: string | null): string | null {
|
||||
if (!size) return null;
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const divisor = gcd(parsed.width, parsed.height);
|
||||
return `${parsed.width / divisor}:${parsed.height / divisor}`;
|
||||
}
|
||||
|
||||
export function inferResolutionFromSize(size: string | null): "1K" | "2K" | "4K" | null {
|
||||
if (!size) return null;
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const longestEdge = Math.max(parsed.width, parsed.height);
|
||||
if (longestEdge <= 1024) return "1K";
|
||||
if (longestEdge <= 2048) return "2K";
|
||||
return "4K";
|
||||
}
|
||||
|
||||
export function getOpenAIAspectRatio(args: Pick<CliArgs, "aspectRatio" | "size">): string {
|
||||
return args.aspectRatio ?? inferAspectRatioFromSize(args.size) ?? "1:1";
|
||||
}
|
||||
|
||||
export function getOpenAIResolution(
|
||||
args: Pick<CliArgs, "imageSize" | "size" | "quality">
|
||||
): "1K" | "2K" | "4K" {
|
||||
if (args.imageSize === "1K" || args.imageSize === "2K" || args.imageSize === "4K") {
|
||||
return args.imageSize;
|
||||
}
|
||||
|
||||
const inferred = inferResolutionFromSize(args.size);
|
||||
if (inferred) return inferred;
|
||||
|
||||
return args.quality === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
export function getOrientationFromAspectRatio(ar: string): "landscape" | "portrait" | null {
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return null;
|
||||
|
||||
const ratio = parsed.width / parsed.height;
|
||||
if (Math.abs(ratio - 1) < 0.1) return null;
|
||||
return ratio > 1 ? "landscape" : "portrait";
|
||||
}
|
||||
|
||||
export function buildOpenAIGenerationsBody(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: Pick<CliArgs, "aspectRatio" | "size" | "quality" | "imageSize" | "imageApiDialect">
|
||||
): OpenAIGenerationsBody {
|
||||
if (getOpenAIImageApiDialect(args) === "ratio-metadata") {
|
||||
const aspectRatio = getOpenAIAspectRatio(args);
|
||||
const metadata: Record<string, string> = {
|
||||
resolution: getOpenAIResolution(args),
|
||||
};
|
||||
const orientation = getOrientationFromAspectRatio(aspectRatio);
|
||||
if (orientation) metadata.orientation = orientation;
|
||||
|
||||
return {
|
||||
model,
|
||||
prompt,
|
||||
size: aspectRatio,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const body: OpenAIGenerationsBody = {
|
||||
model,
|
||||
prompt,
|
||||
size: args.size || getOpenAISize(model, args.aspectRatio, args.quality),
|
||||
};
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = args.quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
@@ -78,18 +188,28 @@ export async function generateImage(
|
||||
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
||||
}
|
||||
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
const imageApiDialect = getOpenAIImageApiDialect(args);
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (imageApiDialect !== "openai-native") {
|
||||
throw new Error(
|
||||
"Reference images are not supported with the ratio-metadata OpenAI dialect yet. Use openai-native, Google, Azure, OpenRouter, MiniMax, Seedream, or Replicate for image-edit workflows."
|
||||
);
|
||||
}
|
||||
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
|
||||
throw new Error(
|
||||
"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-1.5 (or another gpt-image model)."
|
||||
);
|
||||
}
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
|
||||
}
|
||||
|
||||
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
|
||||
return generateWithOpenAIGenerations(
|
||||
baseURL,
|
||||
apiKey,
|
||||
buildOpenAIGenerationsBody(prompt, model, args)
|
||||
);
|
||||
}
|
||||
|
||||
async function generateWithChatCompletions(
|
||||
@@ -129,17 +249,8 @@ async function generateWithChatCompletions(
|
||||
async function generateWithOpenAIGenerations(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
quality: CliArgs["quality"]
|
||||
body: OpenAIGenerationsBody
|
||||
): Promise<Uint8Array> {
|
||||
const body: Record<string, any> = { model, prompt, size };
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildInput,
|
||||
extractOutputUrl,
|
||||
getDefaultModel,
|
||||
getModelFamily,
|
||||
parseModelId,
|
||||
validateArgs,
|
||||
} from "./replicate.ts";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
@@ -16,9 +19,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
@@ -29,10 +35,24 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
};
|
||||
}
|
||||
|
||||
test("Replicate model parsing accepts official formats and rejects malformed ones", () => {
|
||||
assert.deepEqual(parseModelId("google/nano-banana-pro"), {
|
||||
test("Replicate default model now points at nano-banana-2", () => {
|
||||
const previous = process.env.REPLICATE_IMAGE_MODEL;
|
||||
delete process.env.REPLICATE_IMAGE_MODEL;
|
||||
try {
|
||||
assert.equal(getDefaultModel(), "google/nano-banana-2");
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
delete process.env.REPLICATE_IMAGE_MODEL;
|
||||
} else {
|
||||
process.env.REPLICATE_IMAGE_MODEL = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Replicate model parsing and family detection accept supported official ids", () => {
|
||||
assert.deepEqual(parseModelId("google/nano-banana-2"), {
|
||||
owner: "google",
|
||||
name: "nano-banana-pro",
|
||||
name: "nano-banana-2",
|
||||
version: null,
|
||||
});
|
||||
assert.deepEqual(parseModelId("owner/model:abc123"), {
|
||||
@@ -41,46 +61,224 @@ test("Replicate model parsing accepts official formats and rejects malformed one
|
||||
version: "abc123",
|
||||
});
|
||||
|
||||
assert.equal(getModelFamily("google/nano-banana-pro"), "nano-banana");
|
||||
assert.equal(getModelFamily("bytedance/seedream-4.5"), "seedream45");
|
||||
assert.equal(getModelFamily("bytedance/seedream-5-lite"), "seedream5lite");
|
||||
assert.equal(getModelFamily("wan-video/wan-2.7-image"), "wan27image");
|
||||
assert.equal(getModelFamily("wan-video/wan-2.7-image-pro"), "wan27imagepro");
|
||||
assert.equal(getModelFamily("stability-ai/sdxl"), "unknown");
|
||||
|
||||
assert.throws(
|
||||
() => parseModelId("just-a-model-name"),
|
||||
/Invalid Replicate model format/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate input builder maps aspect ratio, image count, quality, and refs", () => {
|
||||
test("Replicate nano-banana input builder maps refs, aspect ratio, and quality presets", () => {
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"google/nano-banana-2",
|
||||
"A robot painter",
|
||||
makeArgs({
|
||||
aspectRatio: "16:9",
|
||||
quality: "2k",
|
||||
n: 3,
|
||||
}),
|
||||
["data:image/png;base64,AAAA"],
|
||||
),
|
||||
{
|
||||
prompt: "A robot painter",
|
||||
aspect_ratio: "16:9",
|
||||
number_of_images: 3,
|
||||
resolution: "2K",
|
||||
output_format: "png",
|
||||
aspect_ratio: "16:9",
|
||||
image_input: ["data:image/png;base64,AAAA"],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput("A robot painter", makeArgs({ quality: "normal" }), ["ref"]),
|
||||
buildInput(
|
||||
"google/nano-banana-2",
|
||||
"A robot painter",
|
||||
makeArgs({ size: "1024x1024", quality: "normal" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A robot painter",
|
||||
aspect_ratio: "match_input_image",
|
||||
resolution: "1K",
|
||||
output_format: "png",
|
||||
image_input: ["ref"],
|
||||
aspect_ratio: "1:1",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate output extraction supports string, array, and object URLs", () => {
|
||||
test("Replicate Seedream and Wan inputs use family-specific request fields", () => {
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"bytedance/seedream-4.5",
|
||||
"A cinematic portrait",
|
||||
makeArgs({ quality: "2k", referenceImages: ["local.png"] }),
|
||||
["data:image/png;base64,AAAA"],
|
||||
),
|
||||
{
|
||||
prompt: "A cinematic portrait",
|
||||
size: "4K",
|
||||
image_input: ["data:image/png;base64,AAAA"],
|
||||
aspect_ratio: "match_input_image",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"bytedance/seedream-4.5",
|
||||
"A cinematic portrait",
|
||||
makeArgs({ size: "1536x1024" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A cinematic portrait",
|
||||
size: "custom",
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"bytedance/seedream-5-lite",
|
||||
"A poster",
|
||||
makeArgs({ aspectRatio: "21:9", quality: "2k" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A poster",
|
||||
size: "3K",
|
||||
aspect_ratio: "21:9",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"wan-video/wan-2.7-image",
|
||||
"A storyboard frame",
|
||||
makeArgs({ aspectRatio: "16:9", quality: "2k" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A storyboard frame",
|
||||
size: "2048*1152",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"wan-video/wan-2.7-image-pro",
|
||||
"Blend these references",
|
||||
makeArgs({ size: "2K", referenceImages: ["a.png", "b.png"] }),
|
||||
["ref-a", "ref-b"],
|
||||
),
|
||||
{
|
||||
prompt: "Blend these references",
|
||||
size: "2K",
|
||||
images: ["ref-a", "ref-b"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate validateArgs blocks misleading multi-output and unsupported family options locally", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"google/nano-banana-2",
|
||||
makeArgs({ n: 2 }),
|
||||
),
|
||||
/exactly one output image/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"bytedance/seedream-4.5",
|
||||
makeArgs({ size: "1K" }),
|
||||
),
|
||||
/2K, 4K, or an explicit WxH size/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"bytedance/seedream-5-lite",
|
||||
makeArgs({ size: "4K" }),
|
||||
),
|
||||
/supports 2K or 3K output/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"wan-video/wan-2.7-image",
|
||||
makeArgs({ referenceImages: new Array(10).fill("ref.png") }),
|
||||
),
|
||||
/at most 9 reference images/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"wan-video/wan-2.7-image-pro",
|
||||
makeArgs({ referenceImages: ["ref.png"], size: "4K" }),
|
||||
),
|
||||
/only supports 4K text-to-image/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs({ aspectRatio: "16:9" }),
|
||||
),
|
||||
/compatibility list/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"google/nano-banana-2",
|
||||
makeArgs({ imageSize: "2K", imageSizeSource: "config" }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"google/nano-banana-2",
|
||||
makeArgs({ imageSize: "2K", imageSizeSource: "cli" }),
|
||||
),
|
||||
/do not use --imageSize/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs({ aspectRatio: "16:9", aspectRatioSource: "config" }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs({ aspectRatio: "16:9", aspectRatioSource: "cli" }),
|
||||
),
|
||||
/compatibility list/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate output extraction supports single outputs and rejects silent multi-image drops", () => {
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: "https://example.com/a.png" } as never),
|
||||
"https://example.com/a.png",
|
||||
@@ -94,6 +292,17 @@ test("Replicate output extraction supports string, array, and object URLs", () =
|
||||
"https://example.com/c.png",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
extractOutputUrl({
|
||||
output: [
|
||||
"https://example.com/one.png",
|
||||
"https://example.com/two.png",
|
||||
],
|
||||
} as never),
|
||||
/supports saving exactly one image/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => extractOutputUrl({ output: { invalid: true } } as never),
|
||||
/Unexpected Replicate output format/,
|
||||
|
||||
@@ -2,10 +2,37 @@ import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
const DEFAULT_MODEL = "google/nano-banana-pro";
|
||||
const DEFAULT_MODEL = "google/nano-banana-2";
|
||||
const SYNC_WAIT_SECONDS = 60;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_MS = 300_000;
|
||||
const DOCUMENTED_REPLICATE_ASPECT_RATIOS = new Set([
|
||||
"1:1",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"3:4",
|
||||
"4:3",
|
||||
"5:4",
|
||||
"4:5",
|
||||
"9:16",
|
||||
"16:9",
|
||||
"21:9",
|
||||
]);
|
||||
|
||||
export type ReplicateModelFamily =
|
||||
| "nano-banana"
|
||||
| "seedream45"
|
||||
| "seedream5lite"
|
||||
| "wan27image"
|
||||
| "wan27imagepro"
|
||||
| "unknown";
|
||||
|
||||
type PixelSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type Seedream45Size = "2K" | "4K" | { width: number; height: number };
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
@@ -20,6 +47,40 @@ function getBaseUrl(): string {
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function normalizeModelId(model: string): string {
|
||||
return model.trim().toLowerCase().split(":")[0]!;
|
||||
}
|
||||
|
||||
export function getModelFamily(model: string): ReplicateModelFamily {
|
||||
const normalized = normalizeModelId(model);
|
||||
|
||||
if (
|
||||
normalized === "google/nano-banana" ||
|
||||
normalized === "google/nano-banana-pro" ||
|
||||
normalized === "google/nano-banana-2"
|
||||
) {
|
||||
return "nano-banana";
|
||||
}
|
||||
|
||||
if (normalized === "bytedance/seedream-4.5") {
|
||||
return "seedream45";
|
||||
}
|
||||
|
||||
if (normalized === "bytedance/seedream-5-lite") {
|
||||
return "seedream5lite";
|
||||
}
|
||||
|
||||
if (normalized === "wan-video/wan-2.7-image") {
|
||||
return "wan27image";
|
||||
}
|
||||
|
||||
if (normalized === "wan-video/wan-2.7-image-pro") {
|
||||
return "wan27imagepro";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function parseModelId(model: string): { owner: string; name: string; version: string | null } {
|
||||
const [ownerName, version] = model.split(":");
|
||||
const parts = ownerName!.split("/");
|
||||
@@ -31,27 +92,219 @@ export function parseModelId(model: string): { owner: string; name: string; vers
|
||||
return { owner: parts[0], name: parts[1], version: version || null };
|
||||
}
|
||||
|
||||
export function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = { prompt };
|
||||
function parsePixelSize(value: string): PixelSize | 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 parseAspectRatio(value: string): PixelSize | null {
|
||||
const match = value.trim().match(/^(\d+)\s*:\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 gcd(a: number, b: number): number {
|
||||
let x = Math.abs(a);
|
||||
let y = Math.abs(b);
|
||||
|
||||
while (y !== 0) {
|
||||
const next = x % y;
|
||||
x = y;
|
||||
y = next;
|
||||
}
|
||||
|
||||
return x || 1;
|
||||
}
|
||||
|
||||
function inferAspectRatioFromSize(size: string): string | null {
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const divisor = gcd(parsed.width, parsed.height);
|
||||
const normalized = `${parsed.width / divisor}:${parsed.height / divisor}`;
|
||||
if (!DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getQualityPreset(args: CliArgs): "normal" | "2k" {
|
||||
return args.quality === "normal" ? "normal" : "2k";
|
||||
}
|
||||
|
||||
function validateDocumentedAspectRatio(model: string, aspectRatio: string): void {
|
||||
if (aspectRatio === "match_input_image") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(aspectRatio)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Replicate model ${model} does not support aspect ratio ${aspectRatio}. Supported values: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
function getRequestedAspectRatio(model: string, args: CliArgs): string | null {
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
return args.aspectRatio;
|
||||
}
|
||||
|
||||
if (!args.size) return null;
|
||||
|
||||
const inferred = inferAspectRatioFromSize(args.size);
|
||||
if (!inferred) {
|
||||
throw new Error(
|
||||
`Replicate model ${model} cannot derive a supported aspect ratio from --size ${args.size}. Use one of: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function getNanoBananaResolution(args: CliArgs): "1K" | "2K" {
|
||||
if (args.size) {
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("Replicate nano-banana --size must be in WxH format, for example 1536x1024.");
|
||||
}
|
||||
|
||||
const longestEdge = Math.max(parsed.width, parsed.height);
|
||||
if (longestEdge <= 1024) return "1K";
|
||||
if (longestEdge <= 2048) return "2K";
|
||||
throw new Error("Replicate nano-banana only supports sizes that map to 1K or 2K output.");
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
function resolveSeedream45Size(args: CliArgs): Seedream45Size {
|
||||
if (args.size) {
|
||||
const upper = args.size.trim().toUpperCase();
|
||||
if (upper === "2K" || upper === "4K") {
|
||||
return upper;
|
||||
}
|
||||
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("Replicate Seedream 4.5 --size must be 2K, 4K, or an explicit WxH size.");
|
||||
}
|
||||
if (parsed.width < 1024 || parsed.width > 4096 || parsed.height < 1024 || parsed.height > 4096) {
|
||||
throw new Error("Replicate Seedream 4.5 custom --size must keep width and height between 1024 and 4096.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "2K" : "4K";
|
||||
}
|
||||
|
||||
function resolveSeedream5LiteSize(args: CliArgs): "2K" | "3K" {
|
||||
if (args.size) {
|
||||
const upper = args.size.trim().toUpperCase();
|
||||
if (upper === "2K" || upper === "3K") {
|
||||
return upper;
|
||||
}
|
||||
|
||||
throw new Error("Replicate Seedream 5 Lite currently supports 2K or 3K output in this tool.");
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "2K" : "3K";
|
||||
}
|
||||
|
||||
function formatCustomWanSize(size: PixelSize): string {
|
||||
return `${size.width}*${size.height}`;
|
||||
}
|
||||
|
||||
function resolveWanSizeFromAspectRatio(
|
||||
aspectRatio: string,
|
||||
maxDimension: number,
|
||||
): string {
|
||||
const parsedRatio = parseAspectRatio(aspectRatio);
|
||||
if (!parsedRatio) {
|
||||
throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${aspectRatio}.`);
|
||||
}
|
||||
|
||||
const scale = Math.min(maxDimension / parsedRatio.width, maxDimension / parsedRatio.height);
|
||||
const width = Math.max(1, Math.floor(parsedRatio.width * scale));
|
||||
const height = Math.max(1, Math.floor(parsedRatio.height * scale));
|
||||
return formatCustomWanSize({ width, height });
|
||||
}
|
||||
|
||||
function resolveWanSize(family: "wan27image" | "wan27imagepro", args: CliArgs): "1K" | "2K" | "4K" | string {
|
||||
const referenceMode = args.referenceImages.length > 0;
|
||||
const maxDimension = family === "wan27imagepro" && !referenceMode ? 4096 : 2048;
|
||||
|
||||
if (args.size) {
|
||||
const upper = args.size.trim().toUpperCase();
|
||||
if (upper === "1K" || upper === "2K" || upper === "4K") {
|
||||
if (upper === "4K" && family !== "wan27imagepro") {
|
||||
throw new Error("Replicate Wan 2.7 Image only supports 1K, 2K, or custom sizes up to 2048px.");
|
||||
}
|
||||
if (upper === "4K" && referenceMode) {
|
||||
throw new Error("Replicate Wan 2.7 Image Pro only supports 4K text-to-image. Remove --ref or lower the size.");
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("Replicate Wan --size must be 1K, 2K, 4K, or an explicit WxH size.");
|
||||
}
|
||||
if (parsed.width > maxDimension || parsed.height > maxDimension) {
|
||||
throw new Error(
|
||||
`Replicate ${family === "wan27imagepro" ? "Wan 2.7 Image Pro" : "Wan 2.7 Image"} custom --size must keep width and height at or below ${maxDimension}px in the current mode.`
|
||||
);
|
||||
}
|
||||
return formatCustomWanSize(parsed);
|
||||
}
|
||||
|
||||
if (args.aspectRatio) {
|
||||
input.aspect_ratio = args.aspectRatio;
|
||||
return resolveWanSizeFromAspectRatio(
|
||||
args.aspectRatio,
|
||||
getQualityPreset(args) === "normal" ? 1024 : 2048,
|
||||
);
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
function buildNanoBananaInput(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
resolution: getNanoBananaResolution(args),
|
||||
output_format: "png",
|
||||
};
|
||||
|
||||
const aspectRatio = getRequestedAspectRatio(model, args);
|
||||
if (aspectRatio) {
|
||||
input.aspect_ratio = aspectRatio;
|
||||
} else if (referenceImages.length > 0) {
|
||||
input.aspect_ratio = "match_input_image";
|
||||
}
|
||||
|
||||
if (args.n > 1) {
|
||||
input.number_of_images = args.n;
|
||||
}
|
||||
|
||||
if (args.quality === "normal") {
|
||||
input.resolution = "1K";
|
||||
} else if (args.quality === "2k") {
|
||||
input.resolution = "2K";
|
||||
}
|
||||
|
||||
input.output_format = "png";
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
input.image_input = referenceImages;
|
||||
}
|
||||
@@ -59,6 +312,158 @@ export function buildInput(prompt: string, args: CliArgs, referenceImages: strin
|
||||
return input;
|
||||
}
|
||||
|
||||
function buildSeedreamInput(
|
||||
family: "seedream45" | "seedream5lite",
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const size = family === "seedream45" ? resolveSeedream45Size(args) : resolveSeedream5LiteSize(args);
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
};
|
||||
|
||||
if (family === "seedream45" && typeof size === "object") {
|
||||
input.size = "custom";
|
||||
input.width = size.width;
|
||||
input.height = size.height;
|
||||
} else {
|
||||
input.size = size;
|
||||
}
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
input.image_input = referenceImages;
|
||||
}
|
||||
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
input.aspect_ratio = args.aspectRatio;
|
||||
} else if (referenceImages.length > 0 && family === "seedream45") {
|
||||
input.aspect_ratio = "match_input_image";
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function buildWanInput(
|
||||
family: "wan27image" | "wan27imagepro",
|
||||
prompt: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
size: resolveWanSize(family, args),
|
||||
};
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
input.images = referenceImages;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export function validateArgs(model: string, args: CliArgs): void {
|
||||
parseModelId(model);
|
||||
|
||||
if (args.n !== 1) {
|
||||
throw new Error("Replicate integration currently supports exactly one output image per request. Remove --n or use --n 1.");
|
||||
}
|
||||
|
||||
if (args.imageSize && args.imageSizeSource !== "config") {
|
||||
throw new Error("Replicate models in baoyu-imagine do not use --imageSize. Use --quality, --ar, or --size instead.");
|
||||
}
|
||||
|
||||
const family = getModelFamily(model);
|
||||
|
||||
if (family === "nano-banana") {
|
||||
if (args.referenceImages.length > 14) {
|
||||
throw new Error("Replicate nano-banana supports at most 14 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
}
|
||||
if (args.size) {
|
||||
getRequestedAspectRatio(model, args);
|
||||
getNanoBananaResolution(args);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "seedream45") {
|
||||
if (args.referenceImages.length > 14) {
|
||||
throw new Error("Replicate Seedream 4.5 supports at most 14 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
}
|
||||
resolveSeedream45Size(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "seedream5lite") {
|
||||
if (args.referenceImages.length > 14) {
|
||||
throw new Error("Replicate Seedream 5 Lite supports at most 14 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
}
|
||||
resolveSeedream5LiteSize(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "wan27image" || family === "wan27imagepro") {
|
||||
if (args.referenceImages.length > 9) {
|
||||
throw new Error("Replicate Wan 2.7 image models support at most 9 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
const parsed = parseAspectRatio(args.aspectRatio);
|
||||
if (!parsed) {
|
||||
throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${args.aspectRatio}.`);
|
||||
}
|
||||
}
|
||||
resolveWanSize(family, args);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExplicitAspectRatio = !!args.aspectRatio && args.aspectRatioSource !== "config";
|
||||
|
||||
if (args.referenceImages.length > 0 || hasExplicitAspectRatio || args.size) {
|
||||
throw new Error(
|
||||
`Replicate model ${model} is not in the baoyu-imagine compatibility list. Supported families: google/nano-banana*, bytedance/seedream-4.5, bytedance/seedream-5-lite, wan-video/wan-2.7-image, wan-video/wan-2.7-image-pro.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultOutputExtension(model: string): ".png" {
|
||||
const _family = getModelFamily(model);
|
||||
return ".png";
|
||||
}
|
||||
|
||||
export function buildInput(
|
||||
model: string,
|
||||
prompt: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const family = getModelFamily(model);
|
||||
|
||||
if (family === "nano-banana") {
|
||||
return buildNanoBananaInput(prompt, model, args, referenceImages);
|
||||
}
|
||||
|
||||
if (family === "seedream45" || family === "seedream5lite") {
|
||||
return buildSeedreamInput(family, prompt, model, args, referenceImages);
|
||||
}
|
||||
|
||||
if (family === "wan27image" || family === "wan27imagepro") {
|
||||
return buildWanInput(family, prompt, args, referenceImages);
|
||||
}
|
||||
|
||||
return { prompt };
|
||||
}
|
||||
|
||||
async function readImageAsDataUrl(p: string): Promise<string> {
|
||||
const buf = await readFile(p);
|
||||
const ext = path.extname(p).toLowerCase();
|
||||
@@ -150,6 +555,11 @@ export function extractOutputUrl(prediction: PredictionResponse): string {
|
||||
if (typeof output === "string") return output;
|
||||
|
||||
if (Array.isArray(output)) {
|
||||
if (output.length !== 1) {
|
||||
throw new Error(
|
||||
`Replicate returned ${output.length} outputs, but baoyu-imagine currently supports saving exactly one image per request.`
|
||||
);
|
||||
}
|
||||
const first = output[0];
|
||||
if (typeof first === "string") return first;
|
||||
}
|
||||
@@ -178,13 +588,14 @@ export async function generateImage(
|
||||
if (!apiToken) throw new Error("REPLICATE_API_TOKEN is required. Get one at https://replicate.com/account/api-tokens");
|
||||
|
||||
const parsedModel = parseModelId(model);
|
||||
validateArgs(model, args);
|
||||
|
||||
const refDataUrls: string[] = [];
|
||||
for (const refPath of args.referenceImages) {
|
||||
refDataUrls.push(await readImageAsDataUrl(refPath));
|
||||
}
|
||||
|
||||
const input = buildInput(prompt, args, refDataUrls);
|
||||
const input = buildInput(model, prompt, args, refDataUrls);
|
||||
|
||||
console.log(`Generating image with Replicate (${model})...`);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Provider =
|
||||
| "seedream"
|
||||
| "azure";
|
||||
export type Quality = "normal" | "2k";
|
||||
export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata";
|
||||
|
||||
export type CliArgs = {
|
||||
prompt: string | null;
|
||||
@@ -18,9 +19,12 @@ export type CliArgs = {
|
||||
provider: Provider | null;
|
||||
model: string | null;
|
||||
aspectRatio: string | null;
|
||||
aspectRatioSource?: "cli" | "task" | "config" | null;
|
||||
size: string | null;
|
||||
quality: Quality | null;
|
||||
imageSize: string | null;
|
||||
imageSizeSource?: "cli" | "task" | "config" | null;
|
||||
imageApiDialect: OpenAIImageApiDialect | null;
|
||||
referenceImages: string[];
|
||||
n: number;
|
||||
batchFile: string | null;
|
||||
@@ -40,6 +44,7 @@ export type BatchTaskInput = {
|
||||
size?: string | null;
|
||||
quality?: Quality | null;
|
||||
imageSize?: "1K" | "2K" | "4K" | null;
|
||||
imageApiDialect?: OpenAIImageApiDialect | null;
|
||||
ref?: string[];
|
||||
n?: number;
|
||||
};
|
||||
@@ -57,6 +62,7 @@ export type ExtendConfig = {
|
||||
default_quality: Quality | null;
|
||||
default_aspect_ratio: string | null;
|
||||
default_image_size: "1K" | "2K" | "4K" | null;
|
||||
default_image_api_dialect: OpenAIImageApiDialect | null;
|
||||
default_model: {
|
||||
google: string | null;
|
||||
openai: string | null;
|
||||
|
||||
@@ -46,6 +46,45 @@ export function stripWrappingQuotes(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
apos: "'",
|
||||
gt: ">",
|
||||
lt: "<",
|
||||
nbsp: " ",
|
||||
quot: '"',
|
||||
};
|
||||
|
||||
function decodeHtmlCodePoint(codePoint: number, fallback: string): string {
|
||||
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, body: string) => {
|
||||
const normalized = body.toLowerCase();
|
||||
if (normalized.startsWith("#x")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(2), 16), entity);
|
||||
}
|
||||
if (normalized.startsWith("#")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(1), 10), entity);
|
||||
}
|
||||
return HTML_ENTITIES[normalized] ?? entity;
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanSummaryText(value: string): string {
|
||||
return decodeHtmlEntities(stripWrappingQuotes(value))
|
||||
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?[a-z][a-z0-9:-]*(?:\s+[^>]*)?>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return stripWrappingQuotes(value);
|
||||
@@ -94,10 +133,11 @@ export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1");
|
||||
const summaryText = cleanSummaryText(cleanText);
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
if (cleanText.length <= maxLen) return cleanText;
|
||||
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||
if (summaryText.length > 20) {
|
||||
if (summaryText.length <= maxLen) return summaryText;
|
||||
return `${summaryText.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,19 +45,24 @@ export function loadCodeThemeCss(themeName: string): string {
|
||||
}
|
||||
|
||||
export function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
|
||||
const escapeHtmlAttribute = (value: string) => value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${meta.title}</title>`,
|
||||
` <title>${escapeHtmlAttribute(meta.title)}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
lines.push(` <meta name="author" content="${escapeHtmlAttribute(meta.author)}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
lines.push(` <meta name="description" content="${escapeHtmlAttribute(meta.description)}" />`);
|
||||
}
|
||||
lines.push(` <style>${css}</style>`);
|
||||
if (codeThemeCss) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
cleanSummaryText,
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
parseFrontmatter,
|
||||
@@ -47,8 +48,9 @@ export async function convertMarkdown(
|
||||
}
|
||||
|
||||
const author = stripWrappingQuotes(frontmatter.author ?? "");
|
||||
let summary = stripWrappingQuotes(frontmatter.description ?? "")
|
||||
const frontmatterSummary = stripWrappingQuotes(frontmatter.description ?? "")
|
||||
|| stripWrappingQuotes(frontmatter.summary ?? "");
|
||||
let summary = cleanSummaryText(frontmatterSummary);
|
||||
if (!summary) {
|
||||
summary = extractSummaryFromBody(body, 120);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,45 @@ export function stripWrappingQuotes(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
apos: "'",
|
||||
gt: ">",
|
||||
lt: "<",
|
||||
nbsp: " ",
|
||||
quot: '"',
|
||||
};
|
||||
|
||||
function decodeHtmlCodePoint(codePoint: number, fallback: string): string {
|
||||
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, body: string) => {
|
||||
const normalized = body.toLowerCase();
|
||||
if (normalized.startsWith("#x")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(2), 16), entity);
|
||||
}
|
||||
if (normalized.startsWith("#")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(1), 10), entity);
|
||||
}
|
||||
return HTML_ENTITIES[normalized] ?? entity;
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanSummaryText(value: string): string {
|
||||
return decodeHtmlEntities(stripWrappingQuotes(value))
|
||||
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?[a-z][a-z0-9:-]*(?:\s+[^>]*)?>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return stripWrappingQuotes(value);
|
||||
@@ -94,10 +133,11 @@ export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1");
|
||||
const summaryText = cleanSummaryText(cleanText);
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
if (cleanText.length <= maxLen) return cleanText;
|
||||
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||
if (summaryText.length > 20) {
|
||||
if (summaryText.length <= maxLen) return summaryText;
|
||||
return `${summaryText.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,19 +45,24 @@ export function loadCodeThemeCss(themeName: string): string {
|
||||
}
|
||||
|
||||
export function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
|
||||
const escapeHtmlAttribute = (value: string) => value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${meta.title}</title>`,
|
||||
` <title>${escapeHtmlAttribute(meta.title)}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
lines.push(` <meta name="author" content="${escapeHtmlAttribute(meta.author)}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
lines.push(` <meta name="description" content="${escapeHtmlAttribute(meta.description)}" />`);
|
||||
}
|
||||
lines.push(` <style>${css}</style>`);
|
||||
if (codeThemeCss) {
|
||||
|
||||
@@ -194,6 +194,54 @@ async function pasteFromClipboardInEditor(session: ChromeSession): Promise<void>
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
async function prepareEditorPasteTarget(
|
||||
session: ChromeSession,
|
||||
context: string,
|
||||
options: { clickEditor?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await session.cdp.send('Target.activateTarget', { targetId: session.targetId }).catch(() => {});
|
||||
await sleep(100);
|
||||
|
||||
if (options.clickEditor) {
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
const ready = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
|
||||
const active = document.activeElement;
|
||||
const selection = window.getSelection();
|
||||
const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);
|
||||
const focusInEditor = !!active && (active === editor || editor.contains(active));
|
||||
if (selectionInEditor || focusInEditor) return true;
|
||||
|
||||
if (${JSON.stringify(Boolean(options.clickEditor))}) {
|
||||
editor.focus();
|
||||
const nextActive = document.activeElement;
|
||||
return nextActive === editor || editor.contains(nextActive);
|
||||
}
|
||||
|
||||
return false;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (ready) return;
|
||||
|
||||
const activeElement = await evaluate<string>(session, `
|
||||
(function() {
|
||||
const el = document.activeElement;
|
||||
if (!el) return '(none)';
|
||||
const id = el.id ? '#' + el.id : '';
|
||||
const className = typeof el.className === 'string' && el.className ? '.' + el.className.split(/\\s+/).join('.') : '';
|
||||
return el.tagName.toLowerCase() + id + className;
|
||||
})()
|
||||
`);
|
||||
throw new Error(`Body editor is not focused before ${context}; active element: ${activeElement}`);
|
||||
}
|
||||
|
||||
async function parseMarkdownWithPlaceholders(
|
||||
markdownPath: string,
|
||||
theme?: string,
|
||||
@@ -567,6 +615,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
|
||||
await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);
|
||||
await sleep(500);
|
||||
await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });
|
||||
console.log('[wechat] Pasting into editor...');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await sleep(3000);
|
||||
@@ -608,6 +657,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await sleep(200);
|
||||
|
||||
console.log('[wechat] Pasting image...');
|
||||
await prepareEditorPasteTarget(session, 'inline image paste');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await sleep(3000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
@@ -620,6 +670,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log(`[wechat] Pasting image: ${img}`);
|
||||
await copyImageToClipboard(img);
|
||||
await sleep(500);
|
||||
await prepareEditorPasteTarget(session, 'leading image paste');
|
||||
await pasteInEditor(session);
|
||||
await sleep(2000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
@@ -627,6 +678,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
|
||||
console.log('[wechat] Typing content...');
|
||||
await prepareEditorPasteTarget(session, 'content typing');
|
||||
await typeText(session, content);
|
||||
await sleep(1000);
|
||||
|
||||
|
||||
@@ -46,6 +46,45 @@ export function stripWrappingQuotes(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
apos: "'",
|
||||
gt: ">",
|
||||
lt: "<",
|
||||
nbsp: " ",
|
||||
quot: '"',
|
||||
};
|
||||
|
||||
function decodeHtmlCodePoint(codePoint: number, fallback: string): string {
|
||||
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, body: string) => {
|
||||
const normalized = body.toLowerCase();
|
||||
if (normalized.startsWith("#x")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(2), 16), entity);
|
||||
}
|
||||
if (normalized.startsWith("#")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(1), 10), entity);
|
||||
}
|
||||
return HTML_ENTITIES[normalized] ?? entity;
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanSummaryText(value: string): string {
|
||||
return decodeHtmlEntities(stripWrappingQuotes(value))
|
||||
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?[a-z][a-z0-9:-]*(?:\s+[^>]*)?>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return stripWrappingQuotes(value);
|
||||
@@ -94,10 +133,11 @@ export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1");
|
||||
const summaryText = cleanSummaryText(cleanText);
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
if (cleanText.length <= maxLen) return cleanText;
|
||||
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||
if (summaryText.length > 20) {
|
||||
if (summaryText.length <= maxLen) return summaryText;
|
||||
return `${summaryText.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,19 +45,24 @@ export function loadCodeThemeCss(themeName: string): string {
|
||||
}
|
||||
|
||||
export function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
|
||||
const escapeHtmlAttribute = (value: string) => value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${meta.title}</title>`,
|
||||
` <title>${escapeHtmlAttribute(meta.title)}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
lines.push(` <meta name="author" content="${escapeHtmlAttribute(meta.author)}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
lines.push(` <meta name="description" content="${escapeHtmlAttribute(meta.description)}" />`);
|
||||
}
|
||||
lines.push(` <style>${css}</style>`);
|
||||
if (codeThemeCss) {
|
||||
|
||||
Reference in New Issue
Block a user