Compare commits

...

6 Commits

Author SHA1 Message Date
Jim Liu 宝玉 b02ceacfd9 chore: release v1.37.0 2026-02-27 18:29:04 -06:00
Jim Liu 宝玉 fdf9007e2c feat(baoyu-danger-x-to-markdown): improve article rendering with inline links and content-based output paths 2026-02-27 18:28:34 -06:00
Jim Liu 宝玉 08cee885d3 chore: release v1.36.0 2026-02-27 10:13:27 -06:00
Jim Liu 宝玉 3bd5fdeb1b feat(baoyu-image-gen): add gemini-3.1-flash-image-preview model and improve first-time setup
- Add gemini-3.1-flash-image-preview to supported Google multimodal models
- Improve preferences loading with blocking first-time setup flow
- Add first-time-setup.md reference for guided configuration
- Update model references in SKILL.md and preferences schema
2026-02-27 10:12:52 -06:00
Jim Liu 宝玉 e5912018f3 Merge pull request #55 from liye71023326/fix/google-proxy-support
fix(baoyu-image-gen): use curl fallback when HTTP proxy is detected
2026-02-26 14:33:55 -06:00
李野 b1f568d03d fix(baoyu-image-gen): use curl fallback for Google API when HTTP proxy is detected
Bun's fetch implementation has a known issue where long-lived connections
through HTTP proxies (e.g., Clash, V2Ray) get their sockets closed
unexpectedly, causing Google image generation requests to fail with
"The socket connection was closed unexpectedly".

This change adds automatic proxy detection and falls back to curl as the
HTTP client when a proxy is configured (via https_proxy, http_proxy,
HTTPS_PROXY, HTTP_PROXY, or ALL_PROXY environment variables). When no
proxy is detected, the original fetch-based implementation is used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:16:15 +08:00
9 changed files with 502 additions and 84 deletions
+1 -1
View File
@@ -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.35.0" "version": "1.37.0"
}, },
"plugins": [ "plugins": [
{ {
+16
View File
@@ -2,6 +2,22 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 1.37.0 - 2026-02-27
### Features
- `baoyu-danger-x-to-markdown`: add inline link rendering for X article content, mapping LINK/MEDIA entities to markdown links
- `baoyu-danger-x-to-markdown`: use content-based slug in output directory path for meaningful folder names
- `baoyu-danger-x-to-markdown`: add atomic media queue for blocks without direct media references
## 1.36.0 - 2026-02-27
### Features
- `baoyu-image-gen`: add `gemini-3.1-flash-image-preview` model support for Google multimodal image generation
- `baoyu-image-gen`: improve first-time setup with blocking preferences flow and guided configuration
### Fixes
- `baoyu-image-gen`: use curl fallback for Google API when HTTP proxy is detected (by @liye71023326)
## 1.35.0 - 2026-02-24 ## 1.35.0 - 2026-02-24
### Features ### Features
+16
View File
@@ -2,6 +2,22 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 1.37.0 - 2026-02-27
### 新功能
- `baoyu-danger-x-to-markdown`:支持 X 文章内联链接渲染,将 LINK/MEDIA 实体映射为 Markdown 链接
- `baoyu-danger-x-to-markdown`:输出目录使用基于内容的 slug,生成更有意义的文件夹名称
- `baoyu-danger-x-to-markdown`:新增 atomic 媒体队列,支持无直接媒体引用的区块
## 1.36.0 - 2026-02-27
### 新功能
- `baoyu-image-gen`:新增 `gemini-3.1-flash-image-preview` Google 多模态图片生成模型支持
- `baoyu-image-gen`:优化首次使用引导流程,支持阻塞式偏好配置
### 修复
- `baoyu-image-gen`:检测到 HTTP 代理时自动回退使用 curl 调用 Google API (by @liye71023326)
## 1.35.0 - 2026-02-24 ## 1.35.0 - 2026-02-24
### 新功能 ### 新功能
@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import readline from "node:readline"; import readline from "node:readline";
import process from "node:process"; import process from "node:process";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { fetchXArticle } from "./graphql.js"; import { fetchXArticle } from "./graphql.js";
import { formatArticleMarkdown } from "./markdown.js"; import { formatArticleMarkdown } from "./markdown.js";
@@ -182,36 +182,32 @@ function sanitizeSlug(input: string): string {
.slice(0, 120); .slice(0, 120);
} }
function formatBackupTimestamp(date: Date = new Date()): string { function extractContentSlug(markdown: string): string {
const pad2 = (n: number) => String(n).padStart(2, "0"); const headingMatch = markdown.match(/^#\s+(.+)$/m);
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2( if (headingMatch?.[1]) {
date.getMinutes() return sanitizeSlug(headingMatch[1].slice(0, 60)).toLowerCase();
)}${pad2(date.getSeconds())}`;
}
async function backupDirIfExists(dir: string, log: (message: string) => void): Promise<void> {
try {
if (!fs.existsSync(dir)) return;
const stat = fs.statSync(dir);
if (!stat.isDirectory()) return;
const backup = `${dir}-backup-${formatBackupTimestamp()}`;
await rename(dir, backup);
log(`[x-to-markdown] Existing directory moved to: ${backup}`);
} catch (error) {
throw new Error(
`Failed to backup existing directory (${dir}): ${error instanceof Error ? error.message : String(error ?? "")}`
);
} }
} const lines = markdown.split("\n");
let inFrontmatter = false;
function resolveDefaultOutputDir(slug: string): string { for (const line of lines) {
return path.resolve(process.cwd(), "x-to-markdown", slug); if (line === "---") {
inFrontmatter = !inFrontmatter;
continue;
}
if (inFrontmatter) continue;
const trimmed = line.trim();
if (trimmed) {
return sanitizeSlug(trimmed.slice(0, 60)).toLowerCase();
}
}
return "untitled";
} }
async function resolveOutputPath( async function resolveOutputPath(
normalizedUrl: string, normalizedUrl: string,
kind: "tweet" | "article", kind: "tweet" | "article",
argsOutput: string | null, argsOutput: string | null,
contentSlug: string,
log: (message: string) => void log: (message: string) => void
): Promise<{ outputDir: string; markdownPath: string; slug: string }> { ): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null; const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
@@ -222,15 +218,14 @@ async function resolveOutputPath(
const idPart = articleId ?? tweetId ?? String(Date.now()); const idPart = articleId ?? tweetId ?? String(Date.now());
const slug = userSlug ?? idPart; const slug = userSlug ?? idPart;
const defaultFileName = kind === "article" ? `${idPart}.md` : `${idPart}.md`; const defaultFileName = `${idPart}.md`;
if (argsOutput) { if (argsOutput) {
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\"); const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
const resolved = path.resolve(argsOutput); const resolved = path.resolve(argsOutput);
try { try {
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) { if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
const outputDir = path.join(resolved, slug); const outputDir = path.join(resolved, slug, contentSlug);
await backupDirIfExists(outputDir, log);
await mkdir(outputDir, { recursive: true }); await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug }; return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
} }
@@ -243,8 +238,7 @@ async function resolveOutputPath(
return { outputDir, markdownPath: resolved, slug }; return { outputDir, markdownPath: resolved, slug };
} }
const outputDir = resolveDefaultOutputDir(slug); const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, contentSlug);
await backupDirIfExists(outputDir, log);
await mkdir(outputDir, { recursive: true }); await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug }; return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
} }
@@ -394,13 +388,15 @@ async function main(): Promise<void> {
} }
const kind = articleId ? ("article" as const) : ("tweet" as const); const kind = articleId ? ("article" as const) : ("tweet" as const);
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
let markdown = let markdown =
kind === "article" && articleId kind === "article" && articleId
? await convertArticleToMarkdown(normalizedUrl, articleId, log) ? await convertArticleToMarkdown(normalizedUrl, articleId, log)
: await tweetToMarkdown(normalizedUrl, { log }); : await tweetToMarkdown(normalizedUrl, { log });
const contentSlug = extractContentSlug(markdown);
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, contentSlug, log);
let mediaResult: LocalizeMarkdownMediaResult | null = null; let mediaResult: LocalizeMarkdownMediaResult | null = null;
if (args.downloadMedia) { if (args.downloadMedia) {
@@ -117,11 +117,114 @@ function resolveEntityMediaLines(
return lines; return lines;
} }
function buildMediaLinkMap(
entityMap: ArticleContentState["entityMap"] | undefined
): Map<number, string> {
const map = new Map<number, string>();
if (!entityMap) return map;
const mediaEntries: { idx: number; key: number }[] = [];
const linkEntries: { key: number; url: string }[] = [];
for (const [idx, entry] of Object.entries(entityMap)) {
const value = entry?.value;
if (!value) continue;
const key = parseInt(entry?.key ?? "", 10);
if (isNaN(key)) continue;
if (value.type === "MEDIA" || value.type === "IMAGE") {
mediaEntries.push({ idx: Number(idx), key });
} else if (value.type === "LINK" && typeof value.data?.url === "string") {
linkEntries.push({ key, url: value.data.url });
}
}
if (mediaEntries.length === 0 || linkEntries.length === 0) return map;
mediaEntries.sort((a, b) => a.key - b.key);
linkEntries.sort((a, b) => a.key - b.key);
const pool = [...linkEntries];
for (const media of mediaEntries) {
if (pool.length === 0) break;
let linkIdx = pool.findIndex((l) => l.key > media.key);
if (linkIdx === -1) linkIdx = 0;
const link = pool.splice(linkIdx, 1)[0]!;
map.set(media.idx, link.url);
}
return map;
}
function renderInlineLinks(
text: string,
entityRanges: Array<{ key?: number; offset?: number; length?: number }>,
entityMap: ArticleContentState["entityMap"] | undefined,
mediaLinkMap: Map<number, string>
): string {
if (!entityMap || entityRanges.length === 0) return text;
const valid = entityRanges.filter(
(r) =>
typeof r.key === "number" &&
typeof r.offset === "number" &&
typeof r.length === "number" &&
r.length > 0
);
if (valid.length === 0) return text;
const sorted = [...valid].sort((a, b) => (b.offset ?? 0) - (a.offset ?? 0));
let result = text;
for (const range of sorted) {
const offset = range.offset!;
const length = range.length!;
const key = range.key!;
const entry = entityMap[String(key)];
const value = entry?.value;
if (!value) continue;
let url: string | undefined;
if (value.type === "LINK" && typeof value.data?.url === "string") {
url = value.data.url;
} else if (value.type === "MEDIA" || value.type === "IMAGE") {
url = mediaLinkMap.get(key);
}
if (!url) continue;
const linkText = result.slice(offset, offset + length);
result =
result.slice(0, offset) +
`[${linkText}](${url})` +
result.slice(offset + length);
}
return result;
}
function buildAtomicMediaQueue(
article: ArticleEntity,
usedUrls: Set<string>
): string[] {
const queue: string[] = [];
for (const entity of article.media_entities ?? []) {
const url = resolveMediaUrl(entity?.media_info);
if (url && !usedUrls.has(url)) {
queue.push(url);
}
}
return queue;
}
function renderContentBlocks( function renderContentBlocks(
blocks: ArticleBlock[], blocks: ArticleBlock[],
entityMap: ArticleContentState["entityMap"] | undefined, entityMap: ArticleContentState["entityMap"] | undefined,
mediaById: Map<string, string>, mediaById: Map<string, string>,
usedUrls: Set<string> usedUrls: Set<string>,
atomicMediaQueue: string[],
mediaLinkMap: Map<number, string>
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null; let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
@@ -157,7 +260,12 @@ function renderContentBlocks(
for (const block of blocks) { for (const block of blocks) {
const type = typeof block?.type === "string" ? block.type : "unstyled"; const type = typeof block?.type === "string" ? block.type : "unstyled";
const text = typeof block?.text === "string" ? block.text : ""; const rawText = typeof block?.text === "string" ? block.text : "";
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
const text =
type !== "atomic" && type !== "code-block"
? renderInlineLinks(rawText, ranges, entityMap, mediaLinkMap)
: rawText;
if (type === "code-block") { if (type === "code-block") {
if (!inCodeBlock) { if (!inCodeBlock) {
@@ -185,6 +293,12 @@ function renderContentBlocks(
const mediaLines = collectMediaLines(block); const mediaLines = collectMediaLines(block);
if (mediaLines.length > 0) { if (mediaLines.length > 0) {
pushBlock(mediaLines, "media"); pushBlock(mediaLines, "media");
} else if (atomicMediaQueue.length > 0) {
const url = atomicMediaQueue.shift()!;
if (!usedUrls.has(url)) {
usedUrls.add(url);
pushBlock([`![](${url})`], "media");
}
} }
continue; continue;
} }
@@ -243,11 +357,6 @@ function renderContentBlocks(
pushBlock([text], "text"); pushBlock([text], "text");
break; break;
} }
const trailingMediaLines = collectMediaLines(block);
if (trailingMediaLines.length > 0) {
pushBlock(trailingMediaLines, "media");
}
} }
if (inCodeBlock) { if (inCodeBlock) {
@@ -284,7 +393,9 @@ export function formatArticleMarkdown(article: unknown): FormatArticleResult {
const blocks = candidate.content_state?.blocks; const blocks = candidate.content_state?.blocks;
const entityMap = candidate.content_state?.entityMap; const entityMap = candidate.content_state?.entityMap;
if (Array.isArray(blocks) && blocks.length > 0) { if (Array.isArray(blocks) && blocks.length > 0) {
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls); const atomicMediaQueue = buildAtomicMediaQueue(candidate, usedUrls);
const mediaLinkMap = buildMediaLinkMap(entityMap);
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls, atomicMediaQueue, mediaLinkMap);
if (rendered.length > 0) { if (rendered.length > 0) {
if (lines.length > 0) lines.push(""); if (lines.length > 0) lines.push("");
lines.push(...rendered); lines.push(...rendered);
+17 -22
View File
@@ -13,33 +13,28 @@ Official API-based image generation. Supports OpenAI, Google, DashScope (阿里
1. `SKILL_DIR` = this SKILL.md file's directory 1. `SKILL_DIR` = this SKILL.md file's directory
2. Script path = `${SKILL_DIR}/scripts/main.ts` 2. Script path = `${SKILL_DIR}/scripts/main.ts`
## Preferences (EXTEND.md) ## Step 0: Load Preferences ⛔ BLOCKING
Use Bash to check EXTEND.md existence (priority order): **CRITICAL**: This step MUST complete BEFORE any image generation. Do NOT skip or defer.
Check EXTEND.md existence (priority: project → user):
```bash ```bash
# Check project-level first
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project" test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user" test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
``` ```
┌──────────────────────────────────────────────────┬───────────────────┐ | Result | Action |
│ Path │ Location │ |--------|--------|
├──────────────────────────────────────────────────┼───────────────────┤ | Found | Load, parse, apply settings. If `default_model.[provider]` is null → ask model only (Flow 2) |
│ .baoyu-skills/baoyu-image-gen/EXTEND.md │ Project directory │ | Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save EXTEND.md → Then continue |
├──────────────────────────────────────────────────┼───────────────────┤
│ $HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md │ User home │
└──────────────────────────────────────────────────┴───────────────────┘
┌───────────┬───────────────────────────────────────────────────────────────────────────┐ **CRITICAL**: If not found, complete the full setup (provider + model + quality + save location) using AskUserQuestion BEFORE generating any images. Generation is BLOCKED until EXTEND.md is created.
│ Result │ Action │
├───────────┼───────────────────────────────────────────────────────────────────────────┤ | Path | Location |
│ Found │ Read, parse, apply settings │ |------|----------|
├───────────┼───────────────────────────────────────────────────────────────────────────┤ | `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
│ Not found │ Use defaults │ | `$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
@@ -87,12 +82,12 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
| `--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) |
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) | | `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) |
| `--model <id>`, `-m` | Model ID (`--ref` with OpenAI requires GPT Image model, e.g. `gpt-image-1.5`) | | `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; OpenAI: `gpt-image-1.5`) |
| `--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 and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI | | `--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 |
| `--n <count>` | Number of images | | `--n <count>` | Number of images |
| `--json` | JSON output | | `--json` | JSON output |
@@ -194,7 +189,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
- Missing API key → error with setup instructions - Missing API key → error with setup instructions
- Generation failure → auto-retry once - Generation failure → auto-retry once
- 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 or OpenAI GPT Image edits) - 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)
## Extension Support ## Extension Support
@@ -0,0 +1,197 @@
---
name: first-time-setup
description: First-time setup and default model selection flow for baoyu-image-gen
---
# First-Time Setup
## Overview
Triggered when:
1. No EXTEND.md found → full setup (provider + model + preferences)
2. EXTEND.md found but `default_model.[provider]` is null → model selection only
## Setup Flow
```
No EXTEND.md found EXTEND.md found, model null
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ AskUserQuestion │ │ AskUserQuestion │
│ (full setup) │ │ (model only) │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ Create EXTEND.md │ │ Update EXTEND.md │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
Continue Continue
```
## Flow 1: No EXTEND.md (Full Setup)
**Language**: Use user's input language or saved language preference.
Use AskUserQuestion with ALL questions in ONE call:
### Question 1: Default Provider
```yaml
header: "Provider"
question: "Default image generation provider?"
options:
- label: "Google (Recommended)"
description: "Gemini multimodal - high quality, reference images, flexible sizes"
- label: "OpenAI"
description: "GPT Image - consistent quality, reliable output"
- label: "DashScope"
description: "Alibaba Cloud - z-image-turbo, good for Chinese content"
- label: "Replicate"
description: "Community models - nano-banana-pro, flexible model selection"
```
### Question 2: Default Google Model
Only show if user selected Google or auto-detect (no explicit provider).
```yaml
header: "Google Model"
question: "Default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"
```
### Question 3: Default Quality
```yaml
header: "Quality"
question: "Default image quality?"
options:
- label: "2k (Recommended)"
description: "2048px - covers, illustrations, infographics"
- label: "normal"
description: "1024px - quick previews, drafts"
```
### Question 4: Save Location
```yaml
header: "Save"
question: "Where to save preferences?"
options:
- label: "Project (Recommended)"
description: ".baoyu-skills/ (this project only)"
- label: "User"
description: "~/.baoyu-skills/ (all projects)"
```
### Save Locations
| Choice | Path | Scope |
|--------|------|-------|
| Project | `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Current project |
| User | `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | All projects |
### EXTEND.md Template
```yaml
---
version: 1
default_provider: [selected provider or null]
default_quality: [selected quality]
default_aspect_ratio: null
default_image_size: null
default_model:
google: [selected google model or null]
openai: null
dashscope: null
replicate: null
---
```
## Flow 2: EXTEND.md Exists, Model Null
When EXTEND.md exists but `default_model.[current_provider]` is null, ask ONLY the model question for the current provider.
### Google Model Selection
```yaml
header: "Google Model"
question: "Choose a default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"
```
### OpenAI Model Selection
```yaml
header: "OpenAI Model"
question: "Choose a default OpenAI image generation model?"
options:
- label: "gpt-image-1.5 (Recommended)"
description: "Latest GPT Image model, high quality"
- label: "gpt-image-1"
description: "Previous generation GPT Image model"
```
### DashScope Model Selection
```yaml
header: "DashScope Model"
question: "Choose a default DashScope image generation model?"
options:
- label: "z-image-turbo (Recommended)"
description: "Fast generation, good quality"
- label: "z-image-ultra"
description: "Higher quality, slower generation"
```
### Replicate Model Selection
```yaml
header: "Replicate Model"
question: "Choose a default Replicate image generation model?"
options:
- label: "google/nano-banana-pro (Recommended)"
description: "Google's fast image model on Replicate"
- label: "google/nano-banana"
description: "Google's base image model on Replicate"
```
### Update EXTEND.md
After user selects a model:
1. Read existing EXTEND.md
2. If `default_model:` section exists → update the provider-specific key
3. If `default_model:` section missing → add the full section:
```yaml
default_model:
google: [value or null]
openai: [value or null]
dashscope: [value or null]
replicate: [value or null]
```
Only set the selected provider's model; leave others as their current value or null.
## After Setup
1. Create directory if needed
2. Write/update EXTEND.md with frontmatter
3. Confirm: "Preferences saved to [path]"
4. Continue with image generation
@@ -20,7 +20,7 @@ default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null
default_image_size: null # 1K|2K|4K|null (Google only, overrides quality) 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" 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"
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"
@@ -1,9 +1,17 @@
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 type { CliArgs } from "../types"; import type { CliArgs } from "../types";
const GOOGLE_MULTIMODAL_MODELS = ["gemini-3-pro-image-preview", "gemini-3-flash-preview"]; const GOOGLE_MULTIMODAL_MODELS = [
const GOOGLE_IMAGEN_MODELS = ["imagen-3.0-generate-002", "imagen-3.0-generate-001"]; "gemini-3-pro-image-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-image-preview",
];
const GOOGLE_IMAGEN_MODELS = [
"imagen-3.0-generate-002",
"imagen-3.0-generate-001",
];
export function getDefaultModel(): string { export function getDefaultModel(): string {
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview"; return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
@@ -33,7 +41,8 @@ function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
} }
function getGoogleBaseUrl(): string { function getGoogleBaseUrl(): string {
const base = process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com"; const base =
process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
return base.replace(/\/+$/g, ""); return base.replace(/\/+$/g, "");
} }
@@ -49,11 +58,46 @@ function toModelPath(model: string): string {
return `models/${modelId}`; return `models/${modelId}`;
} }
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> { function getHttpProxy(): string | null {
const apiKey = getGoogleApiKey(); return (
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required"); process.env.https_proxy ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.HTTP_PROXY ||
process.env.ALL_PROXY ||
null
);
}
const res = await fetch(buildGoogleUrl(pathname), { async function postGoogleJsonViaCurl<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
const proxy = getHttpProxy();
const bodyStr = JSON.stringify(body);
const proxyArgs = proxy ? `-x "${proxy}"` : "";
const result = execSync(
`curl -s --connect-timeout 30 --max-time 300 ${proxyArgs} "${url}" -H "Content-Type: application/json" -H "x-goog-api-key: ${apiKey}" -d @-`,
{ input: bodyStr, maxBuffer: 100 * 1024 * 1024, timeout: 310000 },
);
const parsed = JSON.parse(result.toString()) as any;
if (parsed.error) {
throw new Error(
`Google API error (${parsed.error.code}): ${parsed.error.message}`,
);
}
return parsed as T;
}
async function postGoogleJsonViaFetch<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
const res = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -70,7 +114,30 @@ async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
return (await res.json()) as T; return (await res.json()) as T;
} }
function buildPromptWithAspect(prompt: string, ar: string | null, quality: CliArgs["quality"]): string { async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
const apiKey = getGoogleApiKey();
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required");
const url = buildGoogleUrl(pathname);
const proxy = getHttpProxy();
// When an HTTP proxy is detected, use curl instead of fetch.
// Bun's fetch has a known issue where long-lived connections through
// HTTP proxies get their sockets closed unexpectedly, causing image
// generation requests to fail with "socket connection was closed
// unexpectedly". Using curl as the HTTP client works around this.
if (proxy) {
return postGoogleJsonViaCurl<T>(url, apiKey, body);
}
return postGoogleJsonViaFetch<T>(url, apiKey, body);
}
function buildPromptWithAspect(
prompt: string,
ar: string | null,
quality: CliArgs["quality"],
): string {
let result = prompt; let result = prompt;
if (ar) { if (ar) {
result += ` Aspect ratio: ${ar}.`; result += ` Aspect ratio: ${ar}.`;
@@ -86,7 +153,9 @@ function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
return `${prompt} Aspect ratio: ${ar}.`; return `${prompt} Aspect ratio: ${ar}.`;
} }
async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: string }> { async function readImageAsBase64(
p: string,
): Promise<{ data: string; mimeType: string }> {
const buf = await readFile(p); const buf = await readFile(p);
const ext = path.extname(p).toLowerCase(); const ext = path.extname(p).toLowerCase();
let mimeType = "image/png"; let mimeType = "image/png";
@@ -97,7 +166,9 @@ async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: s
} }
function extractInlineImageData(response: { function extractInlineImageData(response: {
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>; candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}): string | null { }): string | null {
for (const candidate of response.candidates || []) { for (const candidate of response.candidates || []) {
for (const part of candidate.content?.parts || []) { for (const part of candidate.content?.parts || []) {
@@ -112,16 +183,21 @@ function extractPredictedImageData(response: {
predictions?: Array<any>; predictions?: Array<any>;
generatedImages?: Array<any>; generatedImages?: Array<any>;
}): string | null { }): string | null {
const candidates = [...(response.predictions || []), ...(response.generatedImages || [])]; const candidates = [
...(response.predictions || []),
...(response.generatedImages || []),
];
for (const candidate of candidates) { for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue; if (!candidate || typeof candidate !== "object") continue;
if (typeof candidate.imageBytes === "string") return candidate.imageBytes; if (typeof candidate.imageBytes === "string") return candidate.imageBytes;
if (typeof candidate.bytesBase64Encoded === "string") return candidate.bytesBase64Encoded; if (typeof candidate.bytesBase64Encoded === "string")
return candidate.bytesBase64Encoded;
if (typeof candidate.data === "string") return candidate.data; if (typeof candidate.data === "string") return candidate.data;
const image = candidate.image; const image = candidate.image;
if (image && typeof image === "object") { if (image && typeof image === "object") {
if (typeof image.imageBytes === "string") return image.imageBytes; if (typeof image.imageBytes === "string") return image.imageBytes;
if (typeof image.bytesBase64Encoded === "string") return image.bytesBase64Encoded; if (typeof image.bytesBase64Encoded === "string")
return image.bytesBase64Encoded;
if (typeof image.data === "string") return image.data; if (typeof image.data === "string") return image.data;
} }
} }
@@ -131,10 +207,13 @@ function extractPredictedImageData(response: {
async function generateWithGemini( async function generateWithGemini(
prompt: string, prompt: string,
model: string, model: string,
args: CliArgs args: CliArgs,
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio); const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio);
const parts: Array<{ text?: string; inlineData?: { data: string; mimeType: string } }> = []; const parts: Array<{
text?: string;
inlineData?: { data: string; mimeType: string };
}> = [];
for (const refPath of args.referenceImages) { for (const refPath of args.referenceImages) {
const { data, mimeType } = await readImageAsBase64(refPath); const { data, mimeType } = await readImageAsBase64(refPath);
parts.push({ inlineData: { data, mimeType } }); parts.push({ inlineData: { data, mimeType } });
@@ -147,7 +226,9 @@ async function generateWithGemini(
console.log("Generating image with Gemini...", imageConfig); console.log("Generating image with Gemini...", imageConfig);
const response = await postGoogleJson<{ const response = await postGoogleJson<{
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>; candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}>(`${toModelPath(model)}:generateContent`, { }>(`${toModelPath(model)}:generateContent`, {
contents: [ contents: [
{ {
@@ -171,12 +252,18 @@ async function generateWithGemini(
async function generateWithImagen( async function generateWithImagen(
prompt: string, prompt: string,
model: string, model: string,
args: CliArgs args: CliArgs,
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const fullPrompt = buildPromptWithAspect(prompt, args.aspectRatio, args.quality); const fullPrompt = buildPromptWithAspect(
prompt,
args.aspectRatio,
args.quality,
);
const imageSize = getGoogleImageSize(args); const imageSize = getGoogleImageSize(args);
if (imageSize === "4K") { if (imageSize === "4K") {
console.error("Warning: Imagen models do not support 4K imageSize, using 2K instead."); console.error(
"Warning: Imagen models do not support 4K imageSize, using 2K instead.",
);
} }
const parameters: Record<string, unknown> = { const parameters: Record<string, unknown> = {
@@ -212,12 +299,12 @@ async function generateWithImagen(
export async function generateImage( export async function generateImage(
prompt: string, prompt: string,
model: string, model: string,
args: CliArgs args: CliArgs,
): Promise<Uint8Array> { ): Promise<Uint8Array> {
if (isGoogleImagen(model)) { if (isGoogleImagen(model)) {
if (args.referenceImages.length > 0) { if (args.referenceImages.length > 0) {
throw new Error( throw new Error(
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview or gemini-3-flash-preview." "Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
); );
} }
return generateWithImagen(prompt, model, args); return generateWithImagen(prompt, model, args);
@@ -225,7 +312,7 @@ export async function generateImage(
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) { if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
throw new Error( throw new Error(
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview or gemini-3-flash-preview." "Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
); );
} }