mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-17 16:09:47 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 661c74cce4 | |||
| 82445f2a39 | |||
| df9ce95c46 | |||
| 88b433d565 | |||
| 5acef7151b | |||
| e4d4ec8334 | |||
| 4935dc8b1a | |||
| c43ed57ffc | |||
| d96368492e | |||
| cfc647fd38 | |||
| a702513487 | |||
| 6b2b28b421 | |||
| 0df63c3a0b | |||
| 7834d632e0 | |||
| ca9a0a1404 | |||
| 366e7b5403 | |||
| 709e026be1 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.57.0"
|
"version": "1.59.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.59.0 - 2026-03-09
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-image-gen`: add batch parallel image generation and provider-level throttling (by @SeamoonAO)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-image-gen`: restore Google as default provider when multiple keys available
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Improve skill documentation clarity (by @SeamoonAO)
|
||||||
|
|
||||||
|
## 1.58.0 - 2026-03-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Add XDG config path support for EXTEND.md (by @liby)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-post-to-wechat`: surface agent-browser startup errors
|
||||||
|
- `baoyu-post-to-wechat`: harden agent-browser command and eval handling (by @luojiyin1987)
|
||||||
|
- `baoyu-image-gen`: use execFileSync for google curl requests (by @luojiyin1987)
|
||||||
|
- `baoyu-format-markdown`: use spawnSync for autocorrect command (by @luojiyin1987)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Fix CLAUDE dependency statement (by @luojiyin1987)
|
||||||
|
- Add markdown-to-html to README utility skills (by @luojiyin1987)
|
||||||
|
|
||||||
## 1.57.0 - 2026-03-08
|
## 1.57.0 - 2026-03-08
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.59.0 - 2026-03-09
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-image-gen`:新增批量并行图片生成和提供商级别限流 (by @SeamoonAO)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-image-gen`:修复多个 API key 可用时恢复 Google 为默认提供商
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 改进技能文档清晰度 (by @SeamoonAO)
|
||||||
|
|
||||||
|
## 1.58.0 - 2026-03-08
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 新增 EXTEND.md 的 XDG 配置路径支持 (by @liby)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-post-to-wechat`:暴露 agent-browser 启动错误信息
|
||||||
|
- `baoyu-post-to-wechat`:加固 agent-browser 命令和 eval 处理 (by @luojiyin1987)
|
||||||
|
- `baoyu-image-gen`:使用 execFileSync 替代 shell 执行 Google curl 请求 (by @luojiyin1987)
|
||||||
|
- `baoyu-format-markdown`:使用 spawnSync 替代 shell 执行 autocorrect 命令 (by @luojiyin1987)
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 修正 CLAUDE 依赖说明 (by @luojiyin1987)
|
||||||
|
- 将 markdown-to-html 添加到 README 工具技能列表 (by @luojiyin1987)
|
||||||
|
|
||||||
## 1.57.0 - 2026-03-08
|
## 1.57.0 - 2026-03-08
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ ${BUN_X} skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.md
|
|||||||
|
|
||||||
- **Bun**: TypeScript runtime (native `bun` preferred, fallback `npx -y bun`)
|
- **Bun**: TypeScript runtime (native `bun` preferred, fallback `npx -y bun`)
|
||||||
- **Chrome**: Required for `baoyu-danger-gemini-web` auth and `baoyu-post-to-x` automation
|
- **Chrome**: Required for `baoyu-danger-gemini-web` auth and `baoyu-post-to-x` automation
|
||||||
- **No npm packages**: Self-contained TypeScript, no external dependencies
|
- **npm packages (per skill)**: Some skill subprojects include `package.json`/lockfiles and require dependency installation in their own `scripts/` directories
|
||||||
|
|
||||||
## Chrome Profile (Unified)
|
## Chrome Profile (Unified)
|
||||||
|
|
||||||
@@ -472,22 +472,27 @@ Check EXTEND.md existence (priority order):
|
|||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/<skill-name>/EXTEND.md && echo "project"
|
test -f .baoyu-skills/<skill-name>/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/<skill-name>/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/<skill-name>/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/<skill-name>/EXTEND.md" && echo "user"
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
\`\`\`powershell
|
\`\`\`powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/<skill-name>/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/<skill-name>/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/<skill-name>/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/<skill-name>/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/<skill-name>/EXTEND.md") { "user" }
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
┌────────────────────────────────────────────┬───────────────────┐
|
┌────────────────────────────────────────────────────────┬──────────────────────────┐
|
||||||
│ Path │ Location │
|
│ Path │ Location │
|
||||||
├────────────────────────────────────────────┼───────────────────┤
|
├────────────────────────────────────────────────────────┼──────────────────────────┤
|
||||||
│ .baoyu-skills/<skill-name>/EXTEND.md │ Project directory │
|
│ .baoyu-skills/<skill-name>/EXTEND.md │ Project directory │
|
||||||
├────────────────────────────────────────────┼───────────────────┤
|
├────────────────────────────────────────────────────────┼──────────────────────────┤
|
||||||
│ $HOME/.baoyu-skills/<skill-name>/EXTEND.md │ User home │
|
│ $XDG_CONFIG_HOME/baoyu-skills/<skill-name>/EXTEND.md │ XDG config (~/.config) │
|
||||||
└────────────────────────────────────────────┴───────────────────┘
|
├────────────────────────────────────────────────────────┼──────────────────────────┤
|
||||||
|
│ $HOME/.baoyu-skills/<skill-name>/EXTEND.md │ User home (legacy) │
|
||||||
|
└────────────────────────────────────────────────────────┴──────────────────────────┘
|
||||||
|
|
||||||
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Result │ Action │
|
│ Result │ Action │
|
||||||
@@ -523,5 +528,6 @@ Custom configurations via EXTEND.md. See **Preferences** section for paths and s
|
|||||||
**Notes**:
|
**Notes**:
|
||||||
- Replace `<skill-name>` with actual skill name (e.g., `baoyu-cover-image`)
|
- Replace `<skill-name>` with actual skill name (e.g., `baoyu-cover-image`)
|
||||||
- Use `$HOME` instead of `~` for cross-platform compatibility (macOS/Linux/WSL/PowerShell)
|
- Use `$HOME` instead of `~` for cross-platform compatibility (macOS/Linux/WSL/PowerShell)
|
||||||
|
- `$XDG_CONFIG_HOME` defaults to `~/.config` when unset
|
||||||
- Use `test -f` (Bash) or `Test-Path` (PowerShell) for explicit file existence check
|
- Use `test -f` (Bash) or `Test-Path` (PowerShell) for explicit file existence check
|
||||||
- ASCII tables for clear visual formatting
|
- ASCII tables for clear visual formatting
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ Simply tell Claude Code:
|
|||||||
|--------|-------------|--------|
|
|--------|-------------|--------|
|
||||||
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
||||||
| **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
| **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||||
| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [translate](#baoyu-translate) |
|
| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
|
||||||
|
|
||||||
## Update Skills
|
## Update Skills
|
||||||
|
|
||||||
@@ -798,6 +798,21 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
|||||||
| Code/commands | `` `inline` `` or ` ```block``` ` |
|
| Code/commands | `` `inline` `` or ` ```block``` ` |
|
||||||
| Quotes | `>` blockquote |
|
| Quotes | `>` blockquote |
|
||||||
|
|
||||||
|
#### baoyu-markdown-to-html
|
||||||
|
|
||||||
|
Convert markdown files into styled HTML with WeChat-compatible themes, syntax highlighting, and optional bottom citations for external links.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic conversion
|
||||||
|
/baoyu-markdown-to-html article.md
|
||||||
|
|
||||||
|
# Theme + color
|
||||||
|
/baoyu-markdown-to-html article.md --theme grace --color red
|
||||||
|
|
||||||
|
# Convert ordinary external links to bottom citations
|
||||||
|
/baoyu-markdown-to-html article.md --cite
|
||||||
|
```
|
||||||
|
|
||||||
#### baoyu-translate
|
#### baoyu-translate
|
||||||
|
|
||||||
Translate articles and documents between languages with three modes: quick (direct), normal (analysis-informed), and refined (full publication-quality workflow with review and polish).
|
Translate articles and documents between languages with three modes: quick (direct), normal (analysis-informed), and refined (full publication-quality workflow with review and polish).
|
||||||
|
|||||||
+16
-1
@@ -76,7 +76,7 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
||||||
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||||
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [translate](#baoyu-translate) |
|
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
|
||||||
|
|
||||||
## 更新技能
|
## 更新技能
|
||||||
|
|
||||||
@@ -798,6 +798,21 @@ AI 驱动的生成后端。
|
|||||||
| 代码/命令 | `` `行内` `` 或 ` ```代码块``` ` |
|
| 代码/命令 | `` `行内` `` 或 ` ```代码块``` ` |
|
||||||
| 引用 | `>` 引用块 |
|
| 引用 | `>` 引用块 |
|
||||||
|
|
||||||
|
#### baoyu-markdown-to-html
|
||||||
|
|
||||||
|
将 Markdown 文件转换为样式化 HTML,支持微信公众号兼容主题、代码高亮,以及可选的外链底部引用。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 基础转换
|
||||||
|
/baoyu-markdown-to-html article.md
|
||||||
|
|
||||||
|
# 主题 + 颜色
|
||||||
|
/baoyu-markdown-to-html article.md --theme grace --color red
|
||||||
|
|
||||||
|
# 将普通外链转换为文末引用
|
||||||
|
/baoyu-markdown-to-html article.md --cite
|
||||||
|
```
|
||||||
|
|
||||||
#### baoyu-translate
|
#### baoyu-translate
|
||||||
|
|
||||||
三模式翻译技能:快速(直接翻译)、标准(分析后翻译)、精翻(完整出版级工作流,含审校与润色)。
|
三模式翻译技能:快速(直接翻译)、标准(分析后翻译)、精翻(完整出版级工作流,含审校与润色)。
|
||||||
|
|||||||
@@ -55,12 +55,15 @@ See [references/styles.md](references/styles.md) for Core Styles, full gallery,
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-article-illustrator/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-article-illustrator/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -115,6 +118,8 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
|
|||||||
|
|
||||||
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
||||||
|
|
||||||
|
**Execution strategy**: When multiple illustrations have saved prompt files and the task is now plain generation, prefer `baoyu-image-gen` batch mode (`build-batch.ts` → `--batchfile`) over spawning subagents. Use subagents only when each image still needs separate prompt iteration or creative exploration.
|
||||||
|
|
||||||
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
||||||
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
||||||
3. Prompts **MUST** use type-specific templates with structured sections (ZONES / LABELS / COLORS / STYLE / ASPECT)
|
3. Prompts **MUST** use type-specific templates with structured sections (ZONES / LABELS / COLORS / STYLE / ASPECT)
|
||||||
|
|||||||
@@ -88,12 +88,15 @@ Check preferences and existing state, then ask ALL needed questions in ONE AskUs
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-article-illustrator/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-article-illustrator/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -312,6 +315,10 @@ Prompt Files:
|
|||||||
|
|
||||||
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
|
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
|
||||||
|
|
||||||
|
**Execution choice**:
|
||||||
|
- If multiple illustrations already have saved prompt files and the task is now plain generation, prefer `baoyu-image-gen` batch mode (`build-batch.ts` -> `main.ts --batchfile`)
|
||||||
|
- Use subagents only when each illustration still needs separate prompt rewriting, style exploration, or other per-image reasoning before generation
|
||||||
|
|
||||||
**CRITICAL - References in Frontmatter**:
|
**CRITICAL - References in Frontmatter**:
|
||||||
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
||||||
- If style/palette was extracted verbally (no file), append info to prompt BODY instead
|
- If style/palette was extracted verbally (no file), append info to prompt BODY instead
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
type CliArgs = {
|
||||||
|
outlinePath: string | null;
|
||||||
|
promptsDir: string | null;
|
||||||
|
outputPath: string | null;
|
||||||
|
imagesDir: string | null;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
aspectRatio: string;
|
||||||
|
quality: string;
|
||||||
|
jobs: number | null;
|
||||||
|
help: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OutlineEntry = {
|
||||||
|
index: number;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
console.log(`Usage:
|
||||||
|
npx -y tsx scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--outline <path> Path to outline.md
|
||||||
|
--prompts <path> Path to prompts directory
|
||||||
|
--output <path> Path to output batch.json
|
||||||
|
--images-dir <path> Directory for generated images
|
||||||
|
--provider <name> Provider for baoyu-image-gen batch tasks (default: replicate)
|
||||||
|
--model <id> Model for baoyu-image-gen batch tasks (default: google/nano-banana-pro)
|
||||||
|
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
|
||||||
|
--quality <level> Quality for all tasks (default: 2k)
|
||||||
|
--jobs <count> Recommended worker count metadata (optional)
|
||||||
|
-h, --help Show help`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliArgs {
|
||||||
|
const args: CliArgs = {
|
||||||
|
outlinePath: null,
|
||||||
|
promptsDir: null,
|
||||||
|
outputPath: null,
|
||||||
|
imagesDir: null,
|
||||||
|
provider: "replicate",
|
||||||
|
model: "google/nano-banana-pro",
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
quality: "2k",
|
||||||
|
jobs: null,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const current = argv[i]!;
|
||||||
|
if (current === "--outline") args.outlinePath = argv[++i] ?? null;
|
||||||
|
else if (current === "--prompts") args.promptsDir = argv[++i] ?? null;
|
||||||
|
else if (current === "--output") args.outputPath = argv[++i] ?? null;
|
||||||
|
else if (current === "--images-dir") args.imagesDir = argv[++i] ?? null;
|
||||||
|
else if (current === "--provider") args.provider = argv[++i] ?? args.provider;
|
||||||
|
else if (current === "--model") args.model = argv[++i] ?? args.model;
|
||||||
|
else if (current === "--ar") args.aspectRatio = argv[++i] ?? args.aspectRatio;
|
||||||
|
else if (current === "--quality") args.quality = argv[++i] ?? args.quality;
|
||||||
|
else if (current === "--jobs") {
|
||||||
|
const value = argv[++i];
|
||||||
|
args.jobs = value ? parseInt(value, 10) : null;
|
||||||
|
} else if (current === "--help" || current === "-h") {
|
||||||
|
args.help = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOutline(content: string): OutlineEntry[] {
|
||||||
|
const entries: OutlineEntry[] = [];
|
||||||
|
const blocks = content.split(/^## Illustration\s+/m).slice(1);
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const indexMatch = block.match(/^(\d+)/);
|
||||||
|
const filenameMatch = block.match(/\*\*Filename\*\*:\s*(.+)/);
|
||||||
|
if (indexMatch && filenameMatch) {
|
||||||
|
entries.push({
|
||||||
|
index: parseInt(indexMatch[1]!, 10),
|
||||||
|
filename: filenameMatch[1]!.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPromptFile(promptsDir: string, entry: OutlineEntry): Promise<string | null> {
|
||||||
|
const files = await readdir(promptsDir);
|
||||||
|
const prefix = String(entry.index).padStart(2, "0");
|
||||||
|
const match = files.find((f) => f.startsWith(prefix) && f.endsWith(".md"));
|
||||||
|
return match ? path.join(promptsDir, match) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.outlinePath) {
|
||||||
|
console.error("Error: --outline is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!args.promptsDir) {
|
||||||
|
console.error("Error: --prompts is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!args.outputPath) {
|
||||||
|
console.error("Error: --output is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outlineContent = await readFile(args.outlinePath, "utf8");
|
||||||
|
const entries = parseOutline(outlineContent);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.error("No illustration entries found in outline.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const promptFile = await findPromptFile(args.promptsDir, entry);
|
||||||
|
if (!promptFile) {
|
||||||
|
console.error(`Warning: No prompt file found for illustration ${entry.index}, skipping.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageDir = args.imagesDir ?? path.dirname(args.outputPath);
|
||||||
|
tasks.push({
|
||||||
|
id: `illustration-${String(entry.index).padStart(2, "0")}`,
|
||||||
|
promptFiles: [promptFile],
|
||||||
|
image: path.join(imageDir, entry.filename),
|
||||||
|
provider: args.provider,
|
||||||
|
model: args.model,
|
||||||
|
ar: args.aspectRatio,
|
||||||
|
quality: args.quality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: Record<string, unknown> = { tasks };
|
||||||
|
if (args.jobs) output.jobs = args.jobs;
|
||||||
|
|
||||||
|
await writeFile(args.outputPath, JSON.stringify(output, null, 2) + "\n");
|
||||||
|
console.log(`Batch file written: ${args.outputPath} (${tasks.length} tasks)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -39,12 +39,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-comic/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-comic/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-comic/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-comic/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-comic/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-comic/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-comic/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-comic/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-comic/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-comic/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-compress-image/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-compress-image/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-compress-image/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-compress-image/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-compress-image/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-compress-image/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-compress-image/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-compress-image/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-compress-image/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-compress-image/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -134,12 +134,15 @@ Check EXTEND.md existence (priority: project → user):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-cover-image/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-cover-image/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-cover-image/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-cover-image/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-cover-image/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-danger-gemini-web/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-danger-gemini-web/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-danger-gemini-web/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-danger-gemini-web/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-danger-gemini-web/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -83,12 +83,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-format-markdown/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-format-markdown/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-format-markdown/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-format-markdown/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-format-markdown/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-format-markdown/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { execSync } from "child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
export function applyAutocorrect(filePath: string): boolean {
|
export function applyAutocorrect(filePath: string): boolean {
|
||||||
try {
|
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||||
execSync(`npx autocorrect-node --fix "${filePath}"`, { stdio: "inherit" });
|
const result = spawnSync(npxCmd, ["autocorrect-node", "--fix", filePath], {
|
||||||
return true;
|
stdio: "inherit",
|
||||||
} catch {
|
});
|
||||||
return false;
|
return result.status === 0;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-image-gen
|
name: baoyu-image-gen
|
||||||
description: AI image generation with OpenAI, Google, DashScope and Replicate APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images.
|
description: AI image generation with OpenAI, Google, DashScope and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||||
version: 1.56.1
|
version: 1.56.1
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
@@ -31,12 +31,15 @@ Check EXTEND.md existence (priority: project → user):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-image-gen/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-image-gen/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-image-gen/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
|||||||
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
||||||
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
|
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
|
||||||
|
|
||||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
|
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits
|
||||||
|
|
||||||
Schema: `references/config/preferences-schema.md`
|
Schema: `references/config/preferences-schema.md`
|
||||||
|
|
||||||
@@ -88,22 +91,57 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
|||||||
|
|
||||||
# Replicate with specific model
|
# Replicate with specific model
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||||
|
|
||||||
|
# Batch mode with saved prompt files
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json
|
||||||
|
|
||||||
|
# Batch mode with explicit worker count
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Batch File Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobs": 4,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "hero",
|
||||||
|
"promptFiles": ["prompts/hero.md"],
|
||||||
|
"image": "out/hero.png",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model": "google/nano-banana-pro",
|
||||||
|
"ar": "16:9",
|
||||||
|
"quality": "2k"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "diagram",
|
||||||
|
"promptFiles": ["prompts/diagram.md"],
|
||||||
|
"image": "out/diagram.png",
|
||||||
|
"ref": ["references/original.png"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch file's directory. `jobs` is optional (overridden by CLI `--jobs`). Top-level array format (without `jobs` wrapper) is also accepted.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--prompt <text>`, `-p` | Prompt text |
|
| `--prompt <text>`, `-p` | Prompt text |
|
||||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||||
| `--image <path>` | Output image path (required) |
|
| `--image <path>` | Output image path (required in single-image mode) |
|
||||||
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) |
|
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
||||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; OpenAI: `gpt-image-1.5`) |
|
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
||||||
|
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: auto-detect) |
|
||||||
|
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; OpenAI: `gpt-image-1.5`, `gpt-image-1`) |
|
||||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||||
| `--quality normal\|2k` | Quality preset (default: 2k) |
|
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
||||||
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
|
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
|
||||||
| `--ref <files...>` | Reference images. Supported by Google multimodal (`gemini-3-pro-image-preview`, `gemini-3-flash-preview`, `gemini-3.1-flash-image-preview`) and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
|
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, and Replicate |
|
||||||
| `--n <count>` | Number of images |
|
| `--n <count>` | Number of images |
|
||||||
| `--json` | JSON output |
|
| `--json` | JSON output |
|
||||||
|
|
||||||
@@ -123,6 +161,9 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
|||||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||||
|
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Override provider concurrency, e.g. `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY` |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Override provider start gap, e.g. `BAOYU_IMAGE_GEN_REPLICATE_START_INTERVAL_MS` |
|
||||||
|
|
||||||
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||||
|
|
||||||
@@ -167,10 +208,10 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
|||||||
|
|
||||||
## Quality Presets
|
## Quality Presets
|
||||||
|
|
||||||
| Preset | Google imageSize | OpenAI Size | Use Case |
|
| Preset | Google imageSize | OpenAI Size | Replicate resolution | Use Case |
|
||||||
|--------|------------------|-------------|----------|
|
|--------|------------------|-------------|----------------------|----------|
|
||||||
| `normal` | 1K | 1024px | Quick previews |
|
| `normal` | 1K | 1024px | 1K | Quick previews |
|
||||||
| `2k` (default) | 2K | 2048px | Covers, illustrations, infographics |
|
| `2k` (default) | 2K | 2048px | 2K | Covers, illustrations, infographics |
|
||||||
|
|
||||||
**Google imageSize**: Can be overridden with `--imageSize 1K|2K|4K`
|
**Google imageSize**: Can be overridden with `--imageSize 1K|2K|4K`
|
||||||
|
|
||||||
@@ -179,41 +220,48 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
|||||||
Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||||
|
|
||||||
- Google multimodal: uses `imageConfig.aspectRatio`
|
- Google multimodal: uses `imageConfig.aspectRatio`
|
||||||
- Google Imagen: uses `aspectRatio` parameter
|
|
||||||
- OpenAI: maps to closest supported size
|
- OpenAI: maps to closest supported size
|
||||||
|
- Replicate: passes `aspect_ratio` to model; when `--ref` is provided without `--ar`, defaults to `match_input_image`
|
||||||
|
|
||||||
## Generation Mode
|
## Generation Mode
|
||||||
|
|
||||||
**Default**: Sequential generation (one image at a time). This ensures stable output and easier debugging.
|
**Default**: Sequential generation.
|
||||||
|
|
||||||
**Parallel Generation**: Only use when user explicitly requests parallel/concurrent generation.
|
**Batch Parallel Generation**: When `--batchfile` contains 2 or more pending tasks, the script automatically enables parallel generation.
|
||||||
|
|
||||||
| Mode | When to Use |
|
| Mode | When to Use |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| Sequential (default) | Normal usage, single images, small batches |
|
| Sequential (default) | Normal usage, single images, small batches |
|
||||||
| Parallel | User explicitly requests, large batches (10+) |
|
| Parallel batch | Batch mode with 2+ tasks |
|
||||||
|
|
||||||
**Parallel Settings** (when requested):
|
Execution choice:
|
||||||
|
|
||||||
| Setting | Value |
|
| Situation | Preferred approach | Why |
|
||||||
|---------|-------|
|
|-----------|--------------------|-----|
|
||||||
| Recommended concurrency | 4 subagents |
|
| One image, or 1-2 simple images | Sequential | Lower coordination overhead and easier debugging |
|
||||||
| Max concurrency | 8 subagents |
|
| Multiple images already have saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, and gives predictable throughput |
|
||||||
| Use case | Large batch generation when user requests parallel |
|
| Each image still needs separate reasoning, prompt writing, or style exploration | Subagents | The work is still exploratory, so each image may need independent analysis before generation |
|
||||||
|
| Output comes from `baoyu-article-illustrator` with `outline.md` + `prompts/` | Batch (`build-batch.ts` -> `--batchfile`) | That workflow already produces prompt files, so direct batch execution is the intended path |
|
||||||
|
|
||||||
**Agent Implementation** (parallel mode only):
|
Rule of thumb:
|
||||||
```
|
|
||||||
# Launch multiple generations in parallel using Task tool
|
- Prefer batch over subagents once prompt files are already saved and the task is "generate all of these"
|
||||||
# Each Task runs as background subagent with run_in_background=true
|
- Use subagents only when generation is coupled with per-image thinking, rewriting, or divergent creative exploration
|
||||||
# Collect results via TaskOutput when all complete
|
|
||||||
```
|
Parallel behavior:
|
||||||
|
|
||||||
|
- Default worker count is automatic, capped by config, built-in default 10
|
||||||
|
- Provider-specific throttling is applied only in batch mode, and the built-in defaults are tuned for faster throughput while still avoiding obvious RPM bursts
|
||||||
|
- You can override worker count with `--jobs <count>`
|
||||||
|
- Each image retries automatically up to 3 attempts
|
||||||
|
- Final output includes success count, failure count, and per-image failure reasons
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
- Missing API key → error with setup instructions
|
- Missing API key → error with setup instructions
|
||||||
- Generation failure → auto-retry once
|
- Generation failure → auto-retry up to 3 attempts per image
|
||||||
- Invalid aspect ratio → warning, proceed with default
|
- Invalid aspect ratio → warning, proceed with default
|
||||||
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; or OpenAI GPT Image edits)
|
- Reference images with unsupported provider/model → error with fix hint
|
||||||
|
|
||||||
## Extension Support
|
## Extension Support
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,25 @@ default_image_size: null # 1K|2K|4K|null (Google only, overrides quality)
|
|||||||
|
|
||||||
default_model:
|
default_model:
|
||||||
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||||
openai: null # e.g., "gpt-image-1.5"
|
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
||||||
dashscope: null # e.g., "z-image-turbo"
|
dashscope: null # e.g., "z-image-turbo"
|
||||||
replicate: null # e.g., "google/nano-banana-pro"
|
replicate: null # e.g., "google/nano-banana-pro"
|
||||||
|
|
||||||
|
batch:
|
||||||
|
max_workers: 10
|
||||||
|
provider_limits:
|
||||||
|
replicate:
|
||||||
|
concurrency: 5
|
||||||
|
start_interval_ms: 700
|
||||||
|
google:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
openai:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
dashscope:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,6 +56,9 @@ default_model:
|
|||||||
| `default_model.openai` | string\|null | null | OpenAI default model |
|
| `default_model.openai` | string\|null | null | OpenAI default model |
|
||||||
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
||||||
| `default_model.replicate` | string\|null | null | Replicate default model |
|
| `default_model.replicate` | string\|null | null | Replicate default model |
|
||||||
|
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
|
||||||
|
| `batch.provider_limits.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
|
||||||
|
| `batch.provider_limits.<provider>.start_interval_ms` | int\|null | provider default | Minimum gap between request starts per provider |
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@@ -65,5 +84,11 @@ default_model:
|
|||||||
openai: "gpt-image-1.5"
|
openai: "gpt-image-1.5"
|
||||||
dashscope: "z-image-turbo"
|
dashscope: "z-image-turbo"
|
||||||
replicate: "google/nano-banana-pro"
|
replicate: "google/nano-banana-pro"
|
||||||
|
batch:
|
||||||
|
max_workers: 10
|
||||||
|
provider_limits:
|
||||||
|
replicate:
|
||||||
|
concurrency: 5
|
||||||
|
start_interval_ms: 700
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,34 +2,108 @@ import path from "node:path";
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import type { CliArgs, Provider, ExtendConfig } from "./types";
|
import type {
|
||||||
|
BatchFile,
|
||||||
|
BatchTaskInput,
|
||||||
|
CliArgs,
|
||||||
|
ExtendConfig,
|
||||||
|
Provider,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type ProviderModule = {
|
||||||
|
getDefaultModel: () => string;
|
||||||
|
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreparedTask = {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
args: CliArgs;
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
outputPath: string;
|
||||||
|
providerModule: ProviderModule;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskResult = {
|
||||||
|
id: string;
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
outputPath: string;
|
||||||
|
success: boolean;
|
||||||
|
attempts: number;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderRateLimit = {
|
||||||
|
concurrency: number;
|
||||||
|
startIntervalMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoadedBatchTasks = {
|
||||||
|
tasks: BatchTaskInput[];
|
||||||
|
jobs: number | null;
|
||||||
|
batchDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
const DEFAULT_MAX_WORKERS = 10;
|
||||||
|
const POLL_WAIT_MS = 250;
|
||||||
|
const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
||||||
|
replicate: { concurrency: 5, startIntervalMs: 700 },
|
||||||
|
google: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
|
openai: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
|
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
|
};
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
console.log(`Usage:
|
console.log(`Usage:
|
||||||
npx -y bun scripts/main.ts --prompt "A cat" --image cat.png
|
npx -y bun scripts/main.ts --prompt "A cat" --image cat.png
|
||||||
npx -y bun scripts/main.ts --prompt "A landscape" --image landscape.png --ar 16:9
|
|
||||||
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
|
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||||
|
npx -y bun scripts/main.ts --batchfile batch.json
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-p, --prompt <text> Prompt text
|
-p, --prompt <text> Prompt text
|
||||||
--promptfiles <files...> Read prompt from files (concatenated)
|
--promptfiles <files...> Read prompt from files (concatenated)
|
||||||
--image <path> Output image path (required)
|
--image <path> Output image path (required in single-image mode)
|
||||||
|
--batchfile <path> JSON batch file for multi-image generation
|
||||||
|
--jobs <count> Worker count for batch mode (default: auto, max from config, built-in default 10)
|
||||||
--provider google|openai|dashscope|replicate Force provider (auto-detect by default)
|
--provider google|openai|dashscope|replicate Force provider (auto-detect by default)
|
||||||
-m, --model <id> Model ID
|
-m, --model <id> Model ID
|
||||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||||
--size <WxH> Size (e.g., 1024x1024)
|
--size <WxH> Size (e.g., 1024x1024)
|
||||||
--quality normal|2k Quality preset (default: 2k)
|
--quality normal|2k Quality preset (default: 2k)
|
||||||
--imageSize 1K|2K|4K Image size for Google (default: from quality)
|
--imageSize 1K|2K|4K Image size for Google (default: from quality)
|
||||||
--ref <files...> Reference images (Google multimodal or OpenAI edits)
|
--ref <files...> Reference images (Google multimodal, OpenAI GPT Image edits, or Replicate)
|
||||||
--n <count> Number of images (default: 1)
|
--n <count> Number of images for the current task (default: 1)
|
||||||
--json JSON output
|
--json JSON output
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
|
|
||||||
|
Batch file format:
|
||||||
|
{
|
||||||
|
"jobs": 4,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "hero",
|
||||||
|
"promptFiles": ["prompts/hero.md"],
|
||||||
|
"image": "out/hero.png",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model": "google/nano-banana-pro",
|
||||||
|
"ar": "16:9"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
OPENAI_API_KEY OpenAI API key
|
OPENAI_API_KEY OpenAI API key
|
||||||
GOOGLE_API_KEY Google API key
|
GOOGLE_API_KEY Google API key
|
||||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||||
DASHSCOPE_API_KEY DashScope API key (阿里云通义万象)
|
DASHSCOPE_API_KEY DashScope API key
|
||||||
REPLICATE_API_TOKEN Replicate API token
|
REPLICATE_API_TOKEN Replicate API token
|
||||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||||
@@ -40,6 +114,9 @@ Environment variables:
|
|||||||
GOOGLE_BASE_URL Custom Google endpoint
|
GOOGLE_BASE_URL Custom Google endpoint
|
||||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||||
|
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
||||||
|
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
|
||||||
|
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms
|
||||||
|
|
||||||
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||||
}
|
}
|
||||||
@@ -57,6 +134,8 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
imageSize: null,
|
imageSize: null,
|
||||||
referenceImages: [],
|
referenceImages: [],
|
||||||
n: 1,
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
json: false,
|
json: false,
|
||||||
help: false,
|
help: false,
|
||||||
};
|
};
|
||||||
@@ -110,9 +189,26 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (a === "--batchfile") {
|
||||||
|
const v = argv[++i];
|
||||||
|
if (!v) throw new Error("Missing value for --batchfile");
|
||||||
|
out.batchFile = v;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a === "--jobs") {
|
||||||
|
const v = argv[++i];
|
||||||
|
if (!v) throw new Error("Missing value for --jobs");
|
||||||
|
out.jobs = parseInt(v, 10);
|
||||||
|
if (isNaN(out.jobs) || out.jobs < 1) throw new Error(`Invalid worker count: ${v}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (a === "--provider") {
|
if (a === "--provider") {
|
||||||
const v = argv[++i];
|
const v = argv[++i];
|
||||||
if (v !== "google" && v !== "openai" && v !== "dashscope" && v !== "replicate") throw new Error(`Invalid provider: ${v}`);
|
if (v !== "google" && v !== "openai" && v !== "dashscope" && v !== "replicate") {
|
||||||
|
throw new Error(`Invalid provider: ${v}`);
|
||||||
|
}
|
||||||
out.provider = v;
|
out.provider = v;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -228,9 +324,11 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
const config: Partial<ExtendConfig> = {};
|
const config: Partial<ExtendConfig> = {};
|
||||||
const lines = yaml.split("\n");
|
const lines = yaml.split("\n");
|
||||||
let currentKey: string | null = null;
|
let currentKey: string | null = null;
|
||||||
|
let currentProvider: Provider | null = null;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
|
||||||
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
|
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
|
||||||
@@ -247,18 +345,57 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
} else if (key === "default_provider") {
|
} else if (key === "default_provider") {
|
||||||
config.default_provider = value === "null" ? null : (value as Provider);
|
config.default_provider = value === "null" ? null : (value as Provider);
|
||||||
} else if (key === "default_quality") {
|
} else if (key === "default_quality") {
|
||||||
config.default_quality = value === "null" ? null : (value as "normal" | "2k");
|
config.default_quality = value === "null" ? null : value as "normal" | "2k";
|
||||||
} else if (key === "default_aspect_ratio") {
|
} else if (key === "default_aspect_ratio") {
|
||||||
const cleaned = value.replace(/['"]/g, "");
|
const cleaned = value.replace(/['"]/g, "");
|
||||||
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
||||||
} else if (key === "default_image_size") {
|
} else if (key === "default_image_size") {
|
||||||
config.default_image_size = value === "null" ? null : (value as "1K" | "2K" | "4K");
|
config.default_image_size = value === "null" ? null : value as "1K" | "2K" | "4K";
|
||||||
} else if (key === "default_model") {
|
} else if (key === "default_model") {
|
||||||
config.default_model = { google: null, openai: null, dashscope: null, replicate: null };
|
config.default_model = { google: null, openai: null, dashscope: null, replicate: null };
|
||||||
currentKey = "default_model";
|
currentKey = "default_model";
|
||||||
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope" || key === "replicate")) {
|
currentProvider = null;
|
||||||
|
} else if (key === "batch") {
|
||||||
|
config.batch = {};
|
||||||
|
currentKey = "batch";
|
||||||
|
currentProvider = null;
|
||||||
|
} else if (currentKey === "batch" && indent >= 2 && key === "max_workers") {
|
||||||
|
config.batch ??= {};
|
||||||
|
config.batch.max_workers = value === "null" ? null : parseInt(value, 10);
|
||||||
|
} else if (currentKey === "batch" && indent >= 2 && key === "provider_limits") {
|
||||||
|
config.batch ??= {};
|
||||||
|
config.batch.provider_limits ??= {};
|
||||||
|
currentKey = "provider_limits";
|
||||||
|
currentProvider = null;
|
||||||
|
} else if (
|
||||||
|
currentKey === "provider_limits" &&
|
||||||
|
indent >= 4 &&
|
||||||
|
(key === "google" || key === "openai" || key === "dashscope" || key === "replicate")
|
||||||
|
) {
|
||||||
|
config.batch ??= {};
|
||||||
|
config.batch.provider_limits ??= {};
|
||||||
|
config.batch.provider_limits[key] ??= {};
|
||||||
|
currentProvider = key;
|
||||||
|
} else if (
|
||||||
|
currentKey === "default_model" &&
|
||||||
|
(key === "google" || key === "openai" || key === "dashscope" || key === "replicate")
|
||||||
|
) {
|
||||||
const cleaned = value.replace(/['"]/g, "");
|
const cleaned = value.replace(/['"]/g, "");
|
||||||
config.default_model![key] = cleaned === "null" ? null : cleaned;
|
config.default_model![key] = cleaned === "null" ? null : cleaned;
|
||||||
|
} else if (
|
||||||
|
currentKey === "provider_limits" &&
|
||||||
|
currentProvider &&
|
||||||
|
indent >= 6 &&
|
||||||
|
(key === "concurrency" || key === "start_interval_ms")
|
||||||
|
) {
|
||||||
|
config.batch ??= {};
|
||||||
|
config.batch.provider_limits ??= {};
|
||||||
|
const providerLimit = (config.batch.provider_limits[currentProvider] ??= {});
|
||||||
|
if (key === "concurrency") {
|
||||||
|
providerLimit.concurrency = value === "null" ? null : parseInt(value, 10);
|
||||||
|
} else {
|
||||||
|
providerLimit.start_interval_ms = value === "null" ? null : parseInt(value, 10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,7 +417,6 @@ async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
|||||||
const content = await readFile(p, "utf8");
|
const content = await readFile(p, "utf8");
|
||||||
const yaml = extractYamlFrontMatter(content);
|
const yaml = extractYamlFrontMatter(content);
|
||||||
if (!yaml) continue;
|
if (!yaml) continue;
|
||||||
|
|
||||||
return parseSimpleYaml(yaml);
|
return parseSimpleYaml(yaml);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
@@ -300,6 +436,57 @@ function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: string | undefined): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveBatchInt(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isInteger(value) && value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return parsePositiveInt(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredMaxWorkers(extendConfig: Partial<ExtendConfig>): number {
|
||||||
|
const envValue = parsePositiveInt(process.env.BAOYU_IMAGE_GEN_MAX_WORKERS);
|
||||||
|
const configValue = extendConfig.batch?.max_workers ?? null;
|
||||||
|
return Math.max(1, envValue ?? configValue ?? DEFAULT_MAX_WORKERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredProviderRateLimits(
|
||||||
|
extendConfig: Partial<ExtendConfig>
|
||||||
|
): Record<Provider, ProviderRateLimit> {
|
||||||
|
const configured: Record<Provider, ProviderRateLimit> = {
|
||||||
|
replicate: { ...DEFAULT_PROVIDER_RATE_LIMITS.replicate },
|
||||||
|
google: { ...DEFAULT_PROVIDER_RATE_LIMITS.google },
|
||||||
|
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
||||||
|
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const provider of ["replicate", "google", "openai", "dashscope"] as Provider[]) {
|
||||||
|
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
||||||
|
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||||
|
configured[provider] = {
|
||||||
|
concurrency:
|
||||||
|
parsePositiveInt(process.env[`${envPrefix}_CONCURRENCY`]) ??
|
||||||
|
extendLimit?.concurrency ??
|
||||||
|
configured[provider].concurrency,
|
||||||
|
startIntervalMs:
|
||||||
|
parsePositiveInt(process.env[`${envPrefix}_START_INTERVAL_MS`]) ??
|
||||||
|
extendLimit?.start_interval_ms ??
|
||||||
|
configured[provider].startIntervalMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
async function readPromptFromFiles(files: string[]): Promise<string> {
|
async function readPromptFromFiles(files: string[]): Promise<string> {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
@@ -311,9 +498,12 @@ async function readPromptFromFiles(files: string[]): Promise<string> {
|
|||||||
async function readPromptFromStdin(): Promise<string | null> {
|
async function readPromptFromStdin(): Promise<string | null> {
|
||||||
if (process.stdin.isTTY) return null;
|
if (process.stdin.isTTY) return null;
|
||||||
try {
|
try {
|
||||||
const t = await Bun.stdin.text();
|
const chunks: Buffer[] = [];
|
||||||
const v = t.trim();
|
for await (const chunk of process.stdin) {
|
||||||
return v.length > 0 ? v : null;
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const value = Buffer.concat(chunks).toString("utf8").trim();
|
||||||
|
return value.length > 0 ? value : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -327,7 +517,13 @@ function normalizeOutputImagePath(p: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectProvider(args: CliArgs): Provider {
|
function detectProvider(args: CliArgs): Provider {
|
||||||
if (args.referenceImages.length > 0 && args.provider && args.provider !== "google" && args.provider !== "openai" && args.provider !== "replicate") {
|
if (
|
||||||
|
args.referenceImages.length > 0 &&
|
||||||
|
args.provider &&
|
||||||
|
args.provider !== "google" &&
|
||||||
|
args.provider !== "openai" &&
|
||||||
|
args.provider !== "replicate"
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), or --provider replicate."
|
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), or --provider replicate."
|
||||||
);
|
);
|
||||||
@@ -349,7 +545,12 @@ function detectProvider(args: CliArgs): Provider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope", hasReplicate && "replicate"].filter(Boolean) as Provider[];
|
const available = [
|
||||||
|
hasGoogle && "google",
|
||||||
|
hasOpenai && "openai",
|
||||||
|
hasDashscope && "dashscope",
|
||||||
|
hasReplicate && "replicate",
|
||||||
|
].filter(Boolean) as Provider[];
|
||||||
|
|
||||||
if (available.length === 1) return available[0]!;
|
if (available.length === 1) return available[0]!;
|
||||||
if (available.length > 1) return available[0]!;
|
if (available.length > 1) return available[0]!;
|
||||||
@@ -371,11 +572,6 @@ async function validateReferenceImages(referenceImages: string[]): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderModule = {
|
|
||||||
getDefaultModel: () => string;
|
|
||||||
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRetryableGenerationError(error: unknown): boolean {
|
function isRetryableGenerationError(error: unknown): boolean {
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
const nonRetryableMarkers = [
|
const nonRetryableMarkers = [
|
||||||
@@ -384,26 +580,353 @@ function isRetryableGenerationError(error: unknown): boolean {
|
|||||||
"only supported",
|
"only supported",
|
||||||
"No API key found",
|
"No API key found",
|
||||||
"is required",
|
"is required",
|
||||||
|
"Invalid ",
|
||||||
|
"Unexpected ",
|
||||||
|
"API error (400)",
|
||||||
|
"API error (401)",
|
||||||
|
"API error (402)",
|
||||||
|
"API error (403)",
|
||||||
|
"API error (404)",
|
||||||
|
"temporarily disabled",
|
||||||
];
|
];
|
||||||
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||||
if (provider === "google") {
|
if (provider === "google") return (await import("./providers/google")) as ProviderModule;
|
||||||
return (await import("./providers/google")) as ProviderModule;
|
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
|
||||||
}
|
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
|
||||||
if (provider === "dashscope") {
|
|
||||||
return (await import("./providers/dashscope")) as ProviderModule;
|
|
||||||
}
|
|
||||||
if (provider === "replicate") {
|
|
||||||
return (await import("./providers/replicate")) as ProviderModule;
|
|
||||||
}
|
|
||||||
return (await import("./providers/openai")) as ProviderModule;
|
return (await import("./providers/openai")) as ProviderModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPromptForArgs(args: CliArgs): Promise<string | null> {
|
||||||
|
let prompt: string | null = args.prompt;
|
||||||
|
if (!prompt && args.promptFiles.length > 0) {
|
||||||
|
prompt = await readPromptFromFiles(args.promptFiles);
|
||||||
|
}
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelForProvider(
|
||||||
|
provider: Provider,
|
||||||
|
requestedModel: string | null,
|
||||||
|
extendConfig: Partial<ExtendConfig>,
|
||||||
|
providerModule: ProviderModule
|
||||||
|
): string {
|
||||||
|
if (requestedModel) return requestedModel;
|
||||||
|
if (extendConfig.default_model) {
|
||||||
|
if (provider === "google" && extendConfig.default_model.google) return extendConfig.default_model.google;
|
||||||
|
if (provider === "openai" && extendConfig.default_model.openai) return extendConfig.default_model.openai;
|
||||||
|
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
||||||
|
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
|
||||||
|
}
|
||||||
|
return providerModule.getDefaultModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<PreparedTask> {
|
||||||
|
if (!args.quality) args.quality = "2k";
|
||||||
|
|
||||||
|
const prompt = (await loadPromptForArgs(args)) ?? (await readPromptFromStdin());
|
||||||
|
if (!prompt) throw new Error("Prompt is required");
|
||||||
|
if (!args.imagePath) throw new Error("--image is required");
|
||||||
|
if (args.referenceImages.length > 0) await validateReferenceImages(args.referenceImages);
|
||||||
|
|
||||||
|
const provider = detectProvider(args);
|
||||||
|
const providerModule = await loadProviderModule(provider);
|
||||||
|
const model = getModelForProvider(provider, args.model, extendConfig, providerModule);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "single",
|
||||||
|
prompt,
|
||||||
|
args,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
outputPath: normalizeOutputImagePath(args.imagePath),
|
||||||
|
providerModule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks> {
|
||||||
|
const resolvedBatchFilePath = path.resolve(batchFilePath);
|
||||||
|
const content = await readFile(resolvedBatchFilePath, "utf8");
|
||||||
|
const parsed = JSON.parse(content.replace(/^\uFEFF/, "")) as BatchFile;
|
||||||
|
const batchDir = path.dirname(resolvedBatchFilePath);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return {
|
||||||
|
tasks: parsed,
|
||||||
|
jobs: null,
|
||||||
|
batchDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === "object" && Array.isArray(parsed.tasks)) {
|
||||||
|
const jobs = parsePositiveBatchInt(parsed.jobs);
|
||||||
|
if (parsed.jobs !== undefined && parsed.jobs !== null && jobs === null) {
|
||||||
|
throw new Error("Invalid batch file. jobs must be a positive integer when provided.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tasks: parsed.tasks,
|
||||||
|
jobs,
|
||||||
|
batchDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("Invalid batch file. Expected an array of tasks or an object with a tasks array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBatchPath(batchDir: string, filePath: string): string {
|
||||||
|
return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
|
||||||
|
return {
|
||||||
|
...baseArgs,
|
||||||
|
prompt: task.prompt ?? null,
|
||||||
|
promptFiles: task.promptFiles ? task.promptFiles.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],
|
||||||
|
imagePath: task.image ? resolveBatchPath(batchDir, task.image) : null,
|
||||||
|
provider: task.provider ?? baseArgs.provider ?? null,
|
||||||
|
model: task.model ?? baseArgs.model ?? null,
|
||||||
|
aspectRatio: task.ar ?? baseArgs.aspectRatio ?? null,
|
||||||
|
size: task.size ?? baseArgs.size ?? null,
|
||||||
|
quality: task.quality ?? baseArgs.quality ?? null,
|
||||||
|
imageSize: task.imageSize ?? baseArgs.imageSize ?? null,
|
||||||
|
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],
|
||||||
|
n: task.n ?? baseArgs.n,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: baseArgs.jobs,
|
||||||
|
json: baseArgs.json,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareBatchTasks(
|
||||||
|
args: CliArgs,
|
||||||
|
extendConfig: Partial<ExtendConfig>
|
||||||
|
): Promise<{ tasks: PreparedTask[]; jobs: number | null }> {
|
||||||
|
if (!args.batchFile) throw new Error("--batchfile is required in batch mode");
|
||||||
|
const { tasks: taskInputs, jobs: batchJobs, batchDir } = await loadBatchTasks(args.batchFile);
|
||||||
|
if (taskInputs.length === 0) throw new Error("Batch file does not contain any tasks.");
|
||||||
|
|
||||||
|
const prepared: PreparedTask[] = [];
|
||||||
|
for (let i = 0; i < taskInputs.length; i++) {
|
||||||
|
const task = taskInputs[i]!;
|
||||||
|
const taskArgs = createTaskArgs(args, task, batchDir);
|
||||||
|
const prompt = await loadPromptForArgs(taskArgs);
|
||||||
|
if (!prompt) throw new Error(`Task ${i + 1} is missing prompt or promptFiles.`);
|
||||||
|
if (!taskArgs.imagePath) throw new Error(`Task ${i + 1} is missing image output path.`);
|
||||||
|
if (taskArgs.referenceImages.length > 0) await validateReferenceImages(taskArgs.referenceImages);
|
||||||
|
|
||||||
|
const provider = detectProvider(taskArgs);
|
||||||
|
const providerModule = await loadProviderModule(provider);
|
||||||
|
const model = getModelForProvider(provider, taskArgs.model, extendConfig, providerModule);
|
||||||
|
prepared.push({
|
||||||
|
id: task.id || `task-${String(i + 1).padStart(2, "0")}`,
|
||||||
|
prompt,
|
||||||
|
args: taskArgs,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
outputPath: normalizeOutputImagePath(taskArgs.imagePath),
|
||||||
|
providerModule,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks: prepared,
|
||||||
|
jobs: args.jobs ?? batchJobs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeImage(outputPath: string, imageData: Uint8Array): Promise<void> {
|
||||||
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, imageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePreparedTask(task: PreparedTask): Promise<TaskResult> {
|
||||||
|
console.error(`Using ${task.provider} / ${task.model} for ${task.id}`);
|
||||||
|
console.error(
|
||||||
|
`Switch model: --model <id> | EXTEND.md default_model.${task.provider} | env ${task.provider.toUpperCase()}_IMAGE_MODEL`
|
||||||
|
);
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
while (attempts < MAX_ATTEMPTS) {
|
||||||
|
attempts += 1;
|
||||||
|
try {
|
||||||
|
const imageData = await task.providerModule.generateImage(task.prompt, task.model, task.args);
|
||||||
|
await writeImage(task.outputPath, imageData);
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
provider: task.provider,
|
||||||
|
model: task.model,
|
||||||
|
outputPath: task.outputPath,
|
||||||
|
success: true,
|
||||||
|
attempts,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const canRetry = attempts < MAX_ATTEMPTS && isRetryableGenerationError(error);
|
||||||
|
if (canRetry) {
|
||||||
|
console.error(`[${task.id}] Attempt ${attempts}/${MAX_ATTEMPTS} failed, retrying...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
provider: task.provider,
|
||||||
|
model: task.model,
|
||||||
|
outputPath: task.outputPath,
|
||||||
|
success: false,
|
||||||
|
attempts,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
provider: task.provider,
|
||||||
|
model: task.model,
|
||||||
|
outputPath: task.outputPath,
|
||||||
|
success: false,
|
||||||
|
attempts: MAX_ATTEMPTS,
|
||||||
|
error: "Unknown failure",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProviderGate(providerRateLimits: Record<Provider, ProviderRateLimit>) {
|
||||||
|
const state = new Map<Provider, { active: number; lastStartedAt: number }>();
|
||||||
|
|
||||||
|
return async function acquire(provider: Provider): Promise<() => void> {
|
||||||
|
const limit = providerRateLimits[provider];
|
||||||
|
while (true) {
|
||||||
|
const current = state.get(provider) ?? { active: 0, lastStartedAt: 0 };
|
||||||
|
const now = Date.now();
|
||||||
|
const enoughCapacity = current.active < limit.concurrency;
|
||||||
|
const enoughGap = now - current.lastStartedAt >= limit.startIntervalMs;
|
||||||
|
if (enoughCapacity && enoughGap) {
|
||||||
|
state.set(provider, { active: current.active + 1, lastStartedAt: now });
|
||||||
|
return () => {
|
||||||
|
const latest = state.get(provider) ?? { active: 1, lastStartedAt: now };
|
||||||
|
state.set(provider, {
|
||||||
|
active: Math.max(0, latest.active - 1),
|
||||||
|
lastStartedAt: latest.lastStartedAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_WAIT_MS));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number {
|
||||||
|
const requested = jobs ?? Math.min(taskCount, maxWorkers);
|
||||||
|
return Math.max(1, Math.min(requested, taskCount, maxWorkers));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBatchTasks(
|
||||||
|
tasks: PreparedTask[],
|
||||||
|
jobs: number | null,
|
||||||
|
extendConfig: Partial<ExtendConfig>
|
||||||
|
): Promise<TaskResult[]> {
|
||||||
|
if (tasks.length === 1) {
|
||||||
|
return [await generatePreparedTask(tasks[0]!)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWorkers = getConfiguredMaxWorkers(extendConfig);
|
||||||
|
const providerRateLimits = getConfiguredProviderRateLimits(extendConfig);
|
||||||
|
const acquireProvider = createProviderGate(providerRateLimits);
|
||||||
|
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
||||||
|
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
|
||||||
|
for (const provider of ["replicate", "google", "openai", "dashscope"] as Provider[]) {
|
||||||
|
const limit = providerRateLimits[provider];
|
||||||
|
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = 0;
|
||||||
|
const results: TaskResult[] = new Array(tasks.length);
|
||||||
|
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
while (true) {
|
||||||
|
const currentIndex = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
if (currentIndex >= tasks.length) return;
|
||||||
|
|
||||||
|
const task = tasks[currentIndex]!;
|
||||||
|
const release = await acquireProvider(task.provider);
|
||||||
|
try {
|
||||||
|
results[currentIndex] = await generatePreparedTask(task);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printBatchSummary(results: TaskResult[]): void {
|
||||||
|
const successCount = results.filter((result) => result.success).length;
|
||||||
|
const failureCount = results.length - successCount;
|
||||||
|
|
||||||
|
console.error("");
|
||||||
|
console.error("Batch generation summary:");
|
||||||
|
console.error(`- Total: ${results.length}`);
|
||||||
|
console.error(`- Succeeded: ${successCount}`);
|
||||||
|
console.error(`- Failed: ${failureCount}`);
|
||||||
|
|
||||||
|
if (failureCount > 0) {
|
||||||
|
console.error("Failure reasons:");
|
||||||
|
for (const result of results.filter((item) => !item.success)) {
|
||||||
|
console.error(`- ${result.id}: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitJson(payload: unknown): void {
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSingleMode(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<void> {
|
||||||
|
const task = await prepareSingleTask(args, extendConfig);
|
||||||
|
const result = await generatePreparedTask(task);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || "Generation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.json) {
|
||||||
|
emitJson({
|
||||||
|
savedImage: result.outputPath,
|
||||||
|
provider: result.provider,
|
||||||
|
model: result.model,
|
||||||
|
attempts: result.attempts,
|
||||||
|
prompt: task.prompt.slice(0, 200),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(result.outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBatchMode(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<void> {
|
||||||
|
const { tasks, jobs } = await prepareBatchTasks(args, extendConfig);
|
||||||
|
const results = await runBatchTasks(tasks, jobs, extendConfig);
|
||||||
|
printBatchSummary(results);
|
||||||
|
|
||||||
|
if (args.json) {
|
||||||
|
emitJson({
|
||||||
|
mode: "batch",
|
||||||
|
total: results.length,
|
||||||
|
succeeded: results.filter((item) => item.success).length,
|
||||||
|
failed: results.filter((item) => !item.success).length,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.some((item) => !item.success)) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
if (args.help) {
|
if (args.help) {
|
||||||
printUsage();
|
printUsage();
|
||||||
return;
|
return;
|
||||||
@@ -412,86 +935,18 @@ async function main(): Promise<void> {
|
|||||||
await loadEnv();
|
await loadEnv();
|
||||||
const extendConfig = await loadExtendConfig();
|
const extendConfig = await loadExtendConfig();
|
||||||
const mergedArgs = mergeConfig(args, extendConfig);
|
const mergedArgs = mergeConfig(args, extendConfig);
|
||||||
|
|
||||||
if (!mergedArgs.quality) mergedArgs.quality = "2k";
|
if (!mergedArgs.quality) mergedArgs.quality = "2k";
|
||||||
|
|
||||||
let prompt: string | null = mergedArgs.prompt;
|
if (mergedArgs.batchFile) {
|
||||||
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
|
await runBatchMode(mergedArgs, extendConfig);
|
||||||
if (!prompt) prompt = await readPromptFromStdin();
|
|
||||||
|
|
||||||
if (!prompt) {
|
|
||||||
console.error("Error: Prompt is required");
|
|
||||||
printUsage();
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mergedArgs.imagePath) {
|
await runSingleMode(mergedArgs, extendConfig);
|
||||||
console.error("Error: --image is required");
|
|
||||||
printUsage();
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mergedArgs.referenceImages.length > 0) {
|
|
||||||
await validateReferenceImages(mergedArgs.referenceImages);
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = detectProvider(mergedArgs);
|
|
||||||
const providerModule = await loadProviderModule(provider);
|
|
||||||
|
|
||||||
let model = mergedArgs.model;
|
|
||||||
if (!model && extendConfig.default_model) {
|
|
||||||
if (provider === "google") model = extendConfig.default_model.google ?? null;
|
|
||||||
if (provider === "openai") model = extendConfig.default_model.openai ?? null;
|
|
||||||
if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null;
|
|
||||||
if (provider === "replicate") model = extendConfig.default_model.replicate ?? null;
|
|
||||||
}
|
|
||||||
model = model || providerModule.getDefaultModel();
|
|
||||||
|
|
||||||
const outputPath = normalizeOutputImagePath(mergedArgs.imagePath);
|
|
||||||
|
|
||||||
let imageData: Uint8Array;
|
|
||||||
let retried = false;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
imageData = await providerModule.generateImage(prompt, model, mergedArgs);
|
|
||||||
break;
|
|
||||||
} catch (e) {
|
|
||||||
if (!retried && isRetryableGenerationError(e)) {
|
|
||||||
retried = true;
|
|
||||||
console.error("Generation failed, retrying...");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dir = path.dirname(outputPath);
|
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
await writeFile(outputPath, imageData);
|
|
||||||
|
|
||||||
if (mergedArgs.json) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
savedImage: outputPath,
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
prompt: prompt.slice(0, 200),
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(outputPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((error) => {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error(msg);
|
console.error(message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import type { CliArgs } from "../types";
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
const GOOGLE_MULTIMODAL_MODELS = [
|
const GOOGLE_MULTIMODAL_MODELS = [
|
||||||
@@ -76,14 +76,43 @@ async function postGoogleJsonViaCurl<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const proxy = getHttpProxy();
|
const proxy = getHttpProxy();
|
||||||
const bodyStr = JSON.stringify(body);
|
const bodyStr = JSON.stringify(body);
|
||||||
const proxyArgs = proxy ? `-x "${proxy}"` : "";
|
const args = [
|
||||||
|
"-s",
|
||||||
|
"--connect-timeout",
|
||||||
|
"30",
|
||||||
|
"--max-time",
|
||||||
|
"300",
|
||||||
|
...(proxy ? ["-x", proxy] : []),
|
||||||
|
url,
|
||||||
|
"-H",
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"-H",
|
||||||
|
`x-goog-api-key: ${apiKey}`,
|
||||||
|
"-d",
|
||||||
|
"@-",
|
||||||
|
];
|
||||||
|
|
||||||
const result = execSync(
|
let result = "";
|
||||||
`curl -s --connect-timeout 30 --max-time 300 ${proxyArgs} "${url}" -H "Content-Type: application/json" -H "x-goog-api-key: ${apiKey}" -d @-`,
|
try {
|
||||||
{ input: bodyStr, maxBuffer: 100 * 1024 * 1024, timeout: 310000 },
|
result = execFileSync("curl", args, {
|
||||||
);
|
input: bodyStr,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 100 * 1024 * 1024,
|
||||||
|
timeout: 310000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as { message?: string; stderr?: string | Buffer };
|
||||||
|
const stderrText =
|
||||||
|
typeof e.stderr === "string"
|
||||||
|
? e.stderr
|
||||||
|
: e.stderr
|
||||||
|
? e.stderr.toString("utf8")
|
||||||
|
: "";
|
||||||
|
const details = stderrText.trim() || e.message || "curl request failed";
|
||||||
|
throw new Error(`Google API request failed via curl: ${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = JSON.parse(result.toString()) as any;
|
const parsed = JSON.parse(result) as any;
|
||||||
if (parsed.error) {
|
if (parsed.error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Google API error (${parsed.error.code}): ${parsed.error.message}`,
|
`Google API error (${parsed.error.code}): ${parsed.error.message}`,
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ export async function generateImage(
|
|||||||
const baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
|
const baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
|
||||||
const apiKey = process.env.OPENAI_API_KEY;
|
const apiKey = process.env.OPENAI_API_KEY;
|
||||||
|
|
||||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
|
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
|
||||||
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
||||||
|
|||||||
@@ -36,22 +36,24 @@ function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): R
|
|||||||
|
|
||||||
if (args.aspectRatio) {
|
if (args.aspectRatio) {
|
||||||
input.aspect_ratio = args.aspectRatio;
|
input.aspect_ratio = args.aspectRatio;
|
||||||
|
} else if (referenceImages.length > 0) {
|
||||||
|
input.aspect_ratio = "match_input_image";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.n > 1) {
|
if (args.n > 1) {
|
||||||
input.number_of_images = args.n;
|
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";
|
input.output_format = "png";
|
||||||
|
|
||||||
if (referenceImages.length > 0) {
|
if (referenceImages.length > 0) {
|
||||||
if (referenceImages.length === 1) {
|
input.image_input = referenceImages;
|
||||||
input.image = referenceImages[0];
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < referenceImages.length; i++) {
|
|
||||||
input[`image${i > 0 ? i + 1 : ""}`] = referenceImages[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return input;
|
return input;
|
||||||
|
|||||||
@@ -13,10 +13,34 @@ export type CliArgs = {
|
|||||||
imageSize: string | null;
|
imageSize: string | null;
|
||||||
referenceImages: string[];
|
referenceImages: string[];
|
||||||
n: number;
|
n: number;
|
||||||
|
batchFile: string | null;
|
||||||
|
jobs: number | null;
|
||||||
json: boolean;
|
json: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BatchTaskInput = {
|
||||||
|
id?: string;
|
||||||
|
prompt?: string | null;
|
||||||
|
promptFiles?: string[];
|
||||||
|
image?: string;
|
||||||
|
provider?: Provider | null;
|
||||||
|
model?: string | null;
|
||||||
|
ar?: string | null;
|
||||||
|
size?: string | null;
|
||||||
|
quality?: Quality | null;
|
||||||
|
imageSize?: "1K" | "2K" | "4K" | null;
|
||||||
|
ref?: string[];
|
||||||
|
n?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BatchFile =
|
||||||
|
| BatchTaskInput[]
|
||||||
|
| {
|
||||||
|
tasks: BatchTaskInput[];
|
||||||
|
jobs?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ExtendConfig = {
|
export type ExtendConfig = {
|
||||||
version: number;
|
version: number;
|
||||||
default_provider: Provider | null;
|
default_provider: Provider | null;
|
||||||
@@ -29,4 +53,16 @@ export type ExtendConfig = {
|
|||||||
dashscope: string | null;
|
dashscope: string | null;
|
||||||
replicate: string | null;
|
replicate: string | null;
|
||||||
};
|
};
|
||||||
|
batch?: {
|
||||||
|
max_workers?: number | null;
|
||||||
|
provider_limits?: Partial<
|
||||||
|
Record<
|
||||||
|
Provider,
|
||||||
|
{
|
||||||
|
concurrency?: number | null;
|
||||||
|
start_interval_ms?: number | null;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -147,12 +147,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-infographic/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-infographic/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-infographic/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-infographic/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-infographic/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-infographic/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-infographic/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-infographic/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-infographic/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-infographic/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-markdown-to-html/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-markdown-to-html/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-markdown-to-html/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-markdown-to-html/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-markdown-to-html/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-markdown-to-html/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ function parseExtendYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
export function loadExtendConfig(): Partial<ExtendConfig> {
|
export function loadExtendConfig(): Partial<ExtendConfig> {
|
||||||
const paths = [
|
const paths = [
|
||||||
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||||
|
path.join(
|
||||||
|
process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"),
|
||||||
|
"baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"
|
||||||
|
),
|
||||||
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||||
];
|
];
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
|
|||||||
@@ -36,12 +36,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-post-to-wechat/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-post-to-wechat/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-post-to-wechat/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-post-to-wechat/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-post-to-wechat/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ function parseExtendYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
export function loadExtendConfig(): Partial<ExtendConfig> {
|
export function loadExtendConfig(): Partial<ExtendConfig> {
|
||||||
const paths = [
|
const paths = [
|
||||||
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||||
|
path.join(
|
||||||
|
process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"),
|
||||||
|
"baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"
|
||||||
|
),
|
||||||
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||||
];
|
];
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execSync, spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -10,30 +10,51 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ab(cmd: string, json = false): string {
|
function quoteForLog(arg: string): string {
|
||||||
const fullCmd = `agent-browser --session ${SESSION} ${cmd}${json ? ' --json' : ''}`;
|
return /[\s"'\\]/.test(arg) ? JSON.stringify(arg) : arg;
|
||||||
console.log(`[ab] ${fullCmd}`);
|
|
||||||
try {
|
|
||||||
const result = execSync(fullCmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
||||||
return result.trim();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { stdout?: string; stderr?: string; message?: string };
|
|
||||||
console.error(`[ab] Error: ${err.stderr || err.message}`);
|
|
||||||
return err.stdout || '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function abRaw(args: string[]): { success: boolean; output: string } {
|
function toSafeJsStringLiteral(value: string): string {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
.replace(/\u2028/g, '\\u2028')
|
||||||
|
.replace(/\u2029/g, '\\u2029');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAgentBrowser(args: string[]): {
|
||||||
|
success: boolean;
|
||||||
|
output: string;
|
||||||
|
spawnError?: string;
|
||||||
|
} {
|
||||||
const result = spawnSync('agent-browser', ['--session', SESSION, ...args], {
|
const result = spawnSync('agent-browser', ['--session', SESSION, ...args], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
|
const spawnError = result.error?.message?.trim();
|
||||||
|
const output = result.stdout || result.stderr || '';
|
||||||
return {
|
return {
|
||||||
success: result.status === 0,
|
success: result.status === 0,
|
||||||
output: result.stdout || result.stderr || ''
|
output: output || spawnError || '',
|
||||||
|
spawnError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ab(args: string[], json = false): string {
|
||||||
|
const fullArgs = json ? [...args, '--json'] : args;
|
||||||
|
console.log(`[ab] agent-browser --session ${SESSION} ${fullArgs.map(quoteForLog).join(' ')}`);
|
||||||
|
const result = runAgentBrowser(fullArgs);
|
||||||
|
if (result.spawnError) {
|
||||||
|
throw new Error(`agent-browser failed to start: ${result.spawnError}`);
|
||||||
|
}
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`[ab] Error: ${result.output.trim()}`);
|
||||||
|
}
|
||||||
|
return result.output.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function abRaw(args: string[]): { success: boolean; output: string } {
|
||||||
|
return runAgentBrowser(args);
|
||||||
|
}
|
||||||
|
|
||||||
interface SnapshotElement {
|
interface SnapshotElement {
|
||||||
ref: string;
|
ref: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -99,17 +120,17 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[wechat] Opening WeChat Official Account...');
|
console.log('[wechat] Opening WeChat Official Account...');
|
||||||
ab(`open ${WECHAT_URL} --headed`);
|
ab(['open', WECHAT_URL, '--headed']);
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
|
|
||||||
console.log('[wechat] Checking login status...');
|
console.log('[wechat] Checking login status...');
|
||||||
let url = ab('get url');
|
let url = ab(['get', 'url']);
|
||||||
console.log(`[wechat] Current URL: ${url}`);
|
console.log(`[wechat] Current URL: ${url}`);
|
||||||
|
|
||||||
const waitForLogin = async (timeoutMs = 120_000): Promise<boolean> => {
|
const waitForLogin = async (timeoutMs = 120_000): Promise<boolean> => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
url = ab('get url');
|
url = ab(['get', 'url']);
|
||||||
if (url.includes('/cgi-bin/home')) return true;
|
if (url.includes('/cgi-bin/home')) return true;
|
||||||
console.log('[wechat] Waiting for login...');
|
console.log('[wechat] Waiting for login...');
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
@@ -126,7 +147,7 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
console.log('[wechat] Getting page snapshot...');
|
console.log('[wechat] Getting page snapshot...');
|
||||||
let snapshot = ab('snapshot');
|
let snapshot = ab(['snapshot']);
|
||||||
console.log(snapshot);
|
console.log(snapshot);
|
||||||
|
|
||||||
console.log('[wechat] Looking for "图文" menu...');
|
console.log('[wechat] Looking for "图文" menu...');
|
||||||
@@ -134,16 +155,16 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
|
|
||||||
if (!tuWenRef) {
|
if (!tuWenRef) {
|
||||||
console.log('[wechat] Using eval to find and click menu...');
|
console.log('[wechat] Using eval to find and click menu...');
|
||||||
ab(`eval "document.querySelectorAll('.new-creation__menu .new-creation__menu-item')[2].click()"`);
|
ab(['eval', "document.querySelectorAll('.new-creation__menu .new-creation__menu-item')[2].click()"]);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[wechat] Clicking menu ref: ${tuWenRef}`);
|
console.log(`[wechat] Clicking menu ref: ${tuWenRef}`);
|
||||||
ab(`click ${tuWenRef}`);
|
ab(['click', tuWenRef]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(4000);
|
await sleep(4000);
|
||||||
|
|
||||||
console.log('[wechat] Checking for new tab...');
|
console.log('[wechat] Checking for new tab...');
|
||||||
const tabsOutput = ab('tab');
|
const tabsOutput = ab(['tab']);
|
||||||
console.log(`[wechat] Tabs: ${tabsOutput}`);
|
console.log(`[wechat] Tabs: ${tabsOutput}`);
|
||||||
|
|
||||||
const tabLines = tabsOutput.split('\n');
|
const tabLines = tabsOutput.split('\n');
|
||||||
@@ -153,14 +174,14 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
const tabMatch = tabsOutput.match(/\[(\d+)\].*(?:appmsg|edit)/i);
|
const tabMatch = tabsOutput.match(/\[(\d+)\].*(?:appmsg|edit)/i);
|
||||||
if (tabMatch) {
|
if (tabMatch) {
|
||||||
console.log(`[wechat] Switching to editor tab ${tabMatch[1]}...`);
|
console.log(`[wechat] Switching to editor tab ${tabMatch[1]}...`);
|
||||||
ab(`tab ${tabMatch[1]}`);
|
ab(['tab', tabMatch[1]]);
|
||||||
} else {
|
} else {
|
||||||
const lastTabMatch = tabsOutput.match(/\[(\d+)\]/g);
|
const lastTabMatch = tabsOutput.match(/\[(\d+)\]/g);
|
||||||
if (lastTabMatch && lastTabMatch.length > 1) {
|
if (lastTabMatch && lastTabMatch.length > 1) {
|
||||||
const lastTab = lastTabMatch[lastTabMatch.length - 1].match(/\d+/)?.[0];
|
const lastTab = lastTabMatch[lastTabMatch.length - 1].match(/\d+/)?.[0];
|
||||||
if (lastTab) {
|
if (lastTab) {
|
||||||
console.log(`[wechat] Switching to last tab ${lastTab}...`);
|
console.log(`[wechat] Switching to last tab ${lastTab}...`);
|
||||||
ab(`tab ${lastTab}`);
|
ab(['tab', lastTab]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,38 +189,44 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
|
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
|
|
||||||
url = ab('get url');
|
url = ab(['get', 'url']);
|
||||||
console.log(`[wechat] Editor URL: ${url}`);
|
console.log(`[wechat] Editor URL: ${url}`);
|
||||||
|
|
||||||
console.log('[wechat] Getting editor snapshot...');
|
console.log('[wechat] Getting editor snapshot...');
|
||||||
snapshot = ab('snapshot');
|
snapshot = ab(['snapshot']);
|
||||||
console.log(snapshot.substring(0, 2000));
|
console.log(snapshot.substring(0, 2000));
|
||||||
|
|
||||||
console.log('[wechat] Uploading images...');
|
console.log('[wechat] Uploading images...');
|
||||||
const fileInputSelector = '.js_upload_btn_container input[type=file]';
|
const fileInputSelector = '.js_upload_btn_container input[type=file]';
|
||||||
|
const fileInputSelectorJs = toSafeJsStringLiteral(fileInputSelector);
|
||||||
|
|
||||||
ab(`eval "document.querySelector('${fileInputSelector}').style.display = 'block'"`);
|
ab(['eval', `{
|
||||||
|
const input = document.querySelector(${fileInputSelectorJs});
|
||||||
|
if (input) input.style.display = 'block';
|
||||||
|
}`]);
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const uploadResult = abRaw(['upload', `"${fileInputSelector}"`, ...absoluteImages]);
|
const uploadResult = abRaw(['upload', fileInputSelector, ...absoluteImages]);
|
||||||
console.log(`[wechat] Upload result: ${uploadResult.output}`);
|
console.log(`[wechat] Upload result: ${uploadResult.output}`);
|
||||||
|
|
||||||
if (!uploadResult.success) {
|
if (!uploadResult.success) {
|
||||||
console.log('[wechat] Using alternative upload method...');
|
console.log('[wechat] Using alternative upload method...');
|
||||||
for (const img of absoluteImages) {
|
for (const img of absoluteImages) {
|
||||||
console.log(`[wechat] Uploading: ${img}`);
|
console.log(`[wechat] Uploading: ${img}`);
|
||||||
ab(`eval "
|
const imgUrlJs = toSafeJsStringLiteral(`file://${img}`);
|
||||||
const input = document.querySelector('${fileInputSelector}');
|
const imgFileNameJs = toSafeJsStringLiteral(path.basename(img));
|
||||||
|
ab(['eval', `
|
||||||
|
const input = document.querySelector(${fileInputSelectorJs});
|
||||||
if (input) {
|
if (input) {
|
||||||
const dt = new DataTransfer();
|
const dt = new DataTransfer();
|
||||||
fetch('file://${img}').then(r => r.blob()).then(b => {
|
fetch(${imgUrlJs}).then(r => r.blob()).then(b => {
|
||||||
const file = new File([b], '${path.basename(img)}', { type: 'image/png' });
|
const file = new File([b], ${imgFileNameJs}, { type: 'image/png' });
|
||||||
dt.items.add(file);
|
dt.items.add(file);
|
||||||
input.files = dt.files;
|
input.files = dt.files;
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"`);
|
`]);
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,13 +235,14 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
await sleep(10000);
|
await sleep(10000);
|
||||||
|
|
||||||
console.log('[wechat] Filling title...');
|
console.log('[wechat] Filling title...');
|
||||||
snapshot = ab('snapshot -i');
|
snapshot = ab(['snapshot', '-i']);
|
||||||
const titleRef = findElementByText(snapshot, 'title') || findElementByText(snapshot, '标题');
|
const titleRef = findElementByText(snapshot, 'title') || findElementByText(snapshot, '标题');
|
||||||
|
|
||||||
if (titleRef) {
|
if (titleRef) {
|
||||||
ab(`fill ${titleRef} "${title.replace(/"/g, '\\"')}"`);
|
ab(['fill', titleRef, title]);
|
||||||
} else {
|
} else {
|
||||||
ab(`eval "const t = document.querySelector('#title'); if(t) { t.value = '${title.replace(/'/g, "\\'")}'; t.dispatchEvent(new Event('input', {bubbles: true})); }"`);
|
const titleJs = toSafeJsStringLiteral(title);
|
||||||
|
ab(['eval', `const t = document.querySelector('#title'); if(t) { t.value = ${titleJs}; t.dispatchEvent(new Event('input', {bubbles: true})); }`]);
|
||||||
}
|
}
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
@@ -222,9 +250,9 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
const editorRef = findElementByText(snapshot, 'js_pmEditorArea') || findElementByText(snapshot, 'textbox');
|
const editorRef = findElementByText(snapshot, 'js_pmEditorArea') || findElementByText(snapshot, 'textbox');
|
||||||
|
|
||||||
if (editorRef) {
|
if (editorRef) {
|
||||||
ab(`click ${editorRef}`);
|
ab(['click', editorRef]);
|
||||||
} else {
|
} else {
|
||||||
ab(`eval "document.querySelector('.js_pmEditorArea')?.click()"`);
|
ab(['eval', "document.querySelector('.js_pmEditorArea')?.click()"]);
|
||||||
}
|
}
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
@@ -233,11 +261,11 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
if (line.length > 0) {
|
if (line.length > 0) {
|
||||||
const escapedLine = line.replace(/"/g, '\\"').replace(/'/g, "\\'");
|
const lineJs = toSafeJsStringLiteral(line);
|
||||||
ab(`eval "document.execCommand('insertText', false, '${escapedLine}')"`);
|
ab(['eval', `document.execCommand('insertText', false, ${lineJs})`]);
|
||||||
}
|
}
|
||||||
if (i < lines.length - 1) {
|
if (i < lines.length - 1) {
|
||||||
ab('press Enter');
|
ab(['press', 'Enter']);
|
||||||
}
|
}
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
}
|
}
|
||||||
@@ -249,9 +277,9 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
console.log('[wechat] Saving as draft...');
|
console.log('[wechat] Saving as draft...');
|
||||||
const submitRef = findElementByText(snapshot, 'js_submit') || findElementByText(snapshot, '保存');
|
const submitRef = findElementByText(snapshot, 'js_submit') || findElementByText(snapshot, '保存');
|
||||||
if (submitRef) {
|
if (submitRef) {
|
||||||
ab(`click ${submitRef}`);
|
ab(['click', submitRef]);
|
||||||
} else {
|
} else {
|
||||||
ab(`eval "document.querySelector('#js_submit')?.click()"`);
|
ab(['eval', "document.querySelector('#js_submit')?.click()"]);
|
||||||
}
|
}
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
console.log('[wechat] Draft saved!');
|
console.log('[wechat] Draft saved!');
|
||||||
@@ -261,7 +289,7 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
|
|
||||||
if (!keepOpen) {
|
if (!keepOpen) {
|
||||||
console.log('[wechat] Closing browser...');
|
console.log('[wechat] Closing browser...');
|
||||||
ab('close');
|
ab(['close']);
|
||||||
} else {
|
} else {
|
||||||
console.log('[wechat] Done. Browser window left open.');
|
console.log('[wechat] Done. Browser window left open.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,12 +40,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-post-to-weibo/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-post-to-weibo/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-post-to-weibo/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-post-to-weibo/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-post-to-weibo/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-post-to-weibo/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-post-to-x/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-post-to-x/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-post-to-x/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-post-to-x/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-post-to-x/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -203,12 +203,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-slide-deck/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-slide-deck/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-slide-deck/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-slide-deck/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-slide-deck/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-slide-deck/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-translate/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-translate/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-translate/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-translate/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-translate/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-translate/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-translate/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-translate/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-translate/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-translate/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -209,6 +212,7 @@ Before translating chunks:
|
|||||||
- **Natural flow**: Use idiomatic target language word order and sentence patterns; break or restructure sentences freely when the source structure doesn't work naturally in the target language
|
- **Natural flow**: Use idiomatic target language word order and sentence patterns; break or restructure sentences freely when the source structure doesn't work naturally in the target language
|
||||||
- **Terminology**: Use standard translations; annotate with original term in parentheses on first occurrence
|
- **Terminology**: Use standard translations; annotate with original term in parentheses on first occurrence
|
||||||
- **Preserve format**: Keep all markdown formatting (headings, bold, italic, images, links, code blocks)
|
- **Preserve format**: Keep all markdown formatting (headings, bold, italic, images, links, code blocks)
|
||||||
|
- **Image-language awareness**: Preserve image references exactly during translation, but after the translation is complete, review referenced images and check whether their likely main text language still matches the translated article language
|
||||||
- **Frontmatter transformation**: If the source has YAML frontmatter, preserve it in the translation with these changes: (1) Rename metadata fields that describe the *source* article — `url`→`sourceUrl`, `title`→`sourceTitle`, `description`→`sourceDescription`, `author`→`sourceAuthor`, `date`→`sourceDate`, and any similar origin-metadata fields — by adding a `source` prefix (camelCase). (2) Translate the values of text fields (title, description, etc.) and add them as new top-level fields. (3) Keep other fields (tags, categories, custom fields) as-is, translating their values where appropriate
|
- **Frontmatter transformation**: If the source has YAML frontmatter, preserve it in the translation with these changes: (1) Rename metadata fields that describe the *source* article — `url`→`sourceUrl`, `title`→`sourceTitle`, `description`→`sourceDescription`, `author`→`sourceAuthor`, `date`→`sourceDate`, and any similar origin-metadata fields — by adding a `source` prefix (camelCase). (2) Translate the values of text fields (title, description, etc.) and add them as new top-level fields. (3) Keep other fields (tags, categories, custom fields) as-is, translating their values where appropriate
|
||||||
- **Respect original**: Maintain original meaning and intent; do not add, remove, or editorialize — but sentence structure and imagery may be adapted freely to serve the meaning
|
- **Respect original**: Maintain original meaning and intent; do not add, remove, or editorialize — but sentence structure and imagery may be adapted freely to serve the meaning
|
||||||
- **Translator's notes**: For terms, concepts, or cultural references that target readers may not understand — due to jargon, cultural gaps, or domain-specific knowledge — add a concise explanatory note in parentheses immediately after the term. The note should explain *what it means* in plain language, not just provide the English original. Format: `译文(English original,通俗解释)`. Calibrate annotation depth to the target audience: general readers need more notes than technical readers. Only add notes where genuinely needed; do not over-annotate obvious terms.
|
- **Translator's notes**: For terms, concepts, or cultural references that target readers may not understand — due to jargon, cultural gaps, or domain-specific knowledge — add a concise explanatory note in parentheses immediately after the term. The note should explain *what it means* in plain language, not just provide the English original. Format: `译文(English original,通俗解释)`. Calibrate annotation depth to the target audience: general readers need more notes than technical readers. Only add notes where genuinely needed; do not over-annotate obvious terms.
|
||||||
@@ -247,6 +251,20 @@ Each step reads the previous step's file and builds on it.
|
|||||||
|
|
||||||
Final translation is always at `translation.md` in the output directory.
|
Final translation is always at `translation.md` in the output directory.
|
||||||
|
|
||||||
|
After the final translation is written, do a lightweight image-language pass:
|
||||||
|
|
||||||
|
1. Collect image references from the translated article
|
||||||
|
2. Identify likely text-heavy images such as covers, screenshots, diagrams, charts, frameworks, and infographics
|
||||||
|
3. If any image likely contains a main text language that does not match the translated article language, proactively remind the user
|
||||||
|
4. The reminder must be a list only. Do not automatically localize those images unless the user asks
|
||||||
|
|
||||||
|
Reminder format (use whatever image syntax the article already uses — standard markdown or wikilink):
|
||||||
|
```text
|
||||||
|
Possible image localization needed:
|
||||||
|
- : likely still contains source-language text while the article is now in target language
|
||||||
|
- : likely text-heavy framework graphic, check whether labels need translation
|
||||||
|
```
|
||||||
|
|
||||||
Display summary:
|
Display summary:
|
||||||
```
|
```
|
||||||
**Translation complete** ({mode} mode)
|
**Translation complete** ({mode} mode)
|
||||||
@@ -258,6 +276,8 @@ Final: {output-dir}/translation.md
|
|||||||
Glossary terms applied: {count}
|
Glossary terms applied: {count}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If mismatched image-language candidates were found, append a short note after the summary telling the user that some embedded images may still need image-text localization, followed by the candidate list.
|
||||||
|
|
||||||
## Extension Support
|
## Extension Support
|
||||||
|
|
||||||
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
||||||
|
|||||||
@@ -38,12 +38,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-url-to-markdown/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-url-to-markdown/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-url-to-markdown/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-url-to-markdown/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-url-to-markdown/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-url-to-markdown/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-url-to-markdown/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-url-to-markdown/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-url-to-markdown/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-url-to-markdown/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -278,12 +278,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-xhs-images/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-xhs-images/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-xhs-images/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-xhs-images/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-xhs-images/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-xhs-images/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user