mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8a5a68a74 | |||
| 688d1760ed | |||
| a701be873b | |||
| 1df5e4974d | |||
| 2677e730b9 | |||
| 080f2eff48 | |||
| bb4f0dc52c | |||
| c731faea8f | |||
| fa155da15d | |||
| 6194f71378 | |||
| 8e88cf4a8b | |||
| 259baff413 | |||
| a42137ff13 | |||
| c6d96d4134 | |||
| e174d642df | |||
| db7eaa2847 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "0.4.1"
|
||||
"version": "0.8.1"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -21,7 +21,8 @@
|
||||
"./skills/baoyu-post-to-wechat",
|
||||
"./skills/baoyu-article-illustrator",
|
||||
"./skills/baoyu-cover-image",
|
||||
"./skills/baoyu-slide-deck"
|
||||
"./skills/baoyu-slide-deck",
|
||||
"./skills/baoyu-comic"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
name: release-skills
|
||||
description: Release workflow for baoyu-skills plugin. This skill should be used when the user wants to create a new release version. It analyzes changes since the last version tag, updates changelogs (EN/CN), bumps the version in marketplace.json, commits changes, and creates a version tag. Supports dry-run mode and breaking change detection.
|
||||
---
|
||||
|
||||
# Release Skills
|
||||
|
||||
Automate the release process for baoyu-skills plugin: analyze changes, update changelogs, bump version, commit, and tag.
|
||||
|
||||
## When to Use
|
||||
|
||||
Trigger this skill when user requests:
|
||||
- "release", "发布", "create release", "new version"
|
||||
- "bump version", "update version"
|
||||
- "prepare release"
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Changes Since Last Tag
|
||||
|
||||
```bash
|
||||
# Get the latest version tag
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
|
||||
# Show changes since last tag
|
||||
git log ${LAST_TAG}..HEAD --oneline
|
||||
git diff ${LAST_TAG}..HEAD --stat
|
||||
```
|
||||
|
||||
Categorize changes by type based on commit messages and file changes:
|
||||
|
||||
| Type | Prefix | Description |
|
||||
|------|--------|-------------|
|
||||
| feat | `feat:` | New features, new skills |
|
||||
| fix | `fix:` | Bug fixes |
|
||||
| docs | `docs:` | Documentation only |
|
||||
| refactor | `refactor:` | Code refactoring |
|
||||
| style | `style:` | Formatting, styling |
|
||||
| chore | `chore:` | Build, tooling, maintenance |
|
||||
|
||||
**Breaking Change Detection**: If changes include:
|
||||
- Removed skills or scripts
|
||||
- Changed API/interfaces
|
||||
- Renamed public functions/options
|
||||
|
||||
Warn user: "Breaking changes detected. Consider major version bump (--major flag)."
|
||||
|
||||
### Step 2: Determine Version Bump
|
||||
|
||||
Current version location: `.claude-plugin/marketplace.json` → `metadata.version`
|
||||
|
||||
Version rules:
|
||||
- **Patch** (0.6.1 → 0.6.2): Bug fixes, docs updates, minor improvements
|
||||
- **Minor** (0.6.x → 0.7.0): New features, new skills, significant enhancements
|
||||
- **Major** (0.x → 1.0): Breaking changes, only when user explicitly requests with `--major`
|
||||
|
||||
Default behavior:
|
||||
- If changes include `feat:` or new skills → Minor bump
|
||||
- Otherwise → Patch bump
|
||||
|
||||
### Step 3: Check and Update README
|
||||
|
||||
Before updating changelogs, check if README files need updates based on changes:
|
||||
|
||||
**When to update README**:
|
||||
- New skills added → Add to skill list
|
||||
- Skills removed → Remove from skill list
|
||||
- Skill renamed → Update references
|
||||
- New features affecting usage → Update usage section
|
||||
- Breaking changes → Update migration notes
|
||||
|
||||
**Files to sync**:
|
||||
- `README.md` (English)
|
||||
- `README.zh.md` (Chinese)
|
||||
|
||||
If changes include new skills or significant feature changes, update both README files to reflect the new capabilities. Keep both files in sync with the same structure and information.
|
||||
|
||||
### Step 4: Update Changelogs
|
||||
|
||||
Files to update:
|
||||
- `CHANGELOG.md` (English)
|
||||
- `CHANGELOG.zh.md` (Chinese)
|
||||
|
||||
Format (insert after header, before previous version):
|
||||
|
||||
```markdown
|
||||
## {NEW_VERSION} - {YYYY-MM-DD}
|
||||
|
||||
### Features
|
||||
- `skill-name`: description of new feature
|
||||
|
||||
### Fixes
|
||||
- `skill-name`: description of fix
|
||||
|
||||
### Documentation
|
||||
- description of docs changes
|
||||
|
||||
### Other
|
||||
- description of other changes
|
||||
```
|
||||
|
||||
Only include sections that have changes. Omit empty sections.
|
||||
|
||||
For Chinese changelog, translate the content maintaining the same structure.
|
||||
|
||||
### Step 5: Update marketplace.json
|
||||
|
||||
Update `.claude-plugin/marketplace.json`:
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"version": "{NEW_VERSION}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Commit Changes
|
||||
|
||||
```bash
|
||||
git add README.md README.zh.md CHANGELOG.md CHANGELOG.zh.md .claude-plugin/marketplace.json
|
||||
git commit -m "chore: release v{NEW_VERSION}"
|
||||
```
|
||||
|
||||
### Step 7: Create Version Tag
|
||||
|
||||
```bash
|
||||
git tag v{NEW_VERSION}
|
||||
```
|
||||
|
||||
**Important**: Do NOT push to remote. User will push manually when ready.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--dry-run` | Preview changes without executing. Show what would be updated. |
|
||||
| `--major` | Force major version bump (0.x → 1.0 or 1.x → 2.0) |
|
||||
| `--minor` | Force minor version bump |
|
||||
| `--patch` | Force patch version bump |
|
||||
| `--pre <tag>` | (Reserved) Create pre-release version, e.g., `--pre beta` → `0.7.0-beta.1` |
|
||||
|
||||
## Dry-Run Mode
|
||||
|
||||
When `--dry-run` is specified:
|
||||
1. Show all changes since last tag
|
||||
2. Show proposed version bump (current → new)
|
||||
3. Show draft changelog entries (EN and CN)
|
||||
4. Show files that would be modified
|
||||
5. Do NOT make any actual changes
|
||||
|
||||
Output format:
|
||||
```
|
||||
=== DRY RUN MODE ===
|
||||
|
||||
Last tag: v0.6.1
|
||||
Proposed version: v0.7.0
|
||||
|
||||
Changes detected:
|
||||
- feat: new skill baoyu-foo added
|
||||
- fix: baoyu-bar timeout issue
|
||||
- docs: updated README
|
||||
|
||||
Changelog preview (EN):
|
||||
## 0.7.0 - 2026-01-17
|
||||
### Features
|
||||
- `baoyu-foo`: new skill for ...
|
||||
### Fixes
|
||||
- `baoyu-bar`: fixed timeout issue
|
||||
|
||||
README updates needed: Yes/No
|
||||
(If yes, show proposed changes)
|
||||
|
||||
Files to modify:
|
||||
- README.md (if updates needed)
|
||||
- README.zh.md (if updates needed)
|
||||
- CHANGELOG.md
|
||||
- CHANGELOG.zh.md
|
||||
- .claude-plugin/marketplace.json
|
||||
|
||||
No changes made. Run without --dry-run to execute.
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/release-skills # Auto-detect version bump
|
||||
/release-skills --dry-run # Preview only
|
||||
/release-skills --minor # Force minor bump
|
||||
/release-skills --major # Force major bump (with confirmation)
|
||||
```
|
||||
|
||||
## Post-Release Reminder
|
||||
|
||||
After successful release, remind user:
|
||||
```
|
||||
Release v{NEW_VERSION} created locally.
|
||||
|
||||
To publish:
|
||||
git push origin main
|
||||
git push origin v{NEW_VERSION}
|
||||
```
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Changelog
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 0.8.1 - 2026-01-17
|
||||
|
||||
### Refactor
|
||||
- `baoyu-gemini-web`: refactors script architecture—consolidates 10 separate files into a structured `gemini-webapi/` module (TypeScript port of gemini_webapi Python library).
|
||||
|
||||
## 0.8.0 - 2026-01-17
|
||||
|
||||
### Features
|
||||
- `baoyu-xhs-images`: adds content analysis framework (`analysis-framework.md`, `outline-template.md`) for structured content breakdown and outline generation.
|
||||
|
||||
### Documentation
|
||||
- `CLAUDE.md`: adds Output Path Convention (directory structure, backup rules) and Image Naming Convention (format, slug rules) to standardize image generation outputs.
|
||||
- Multiple skills: updates file management conventions to use unified directory structure (`[source-name-no-ext]/<skill-suffix>/`).
|
||||
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-cover-image`, `baoyu-slide-deck`, `baoyu-xhs-images`
|
||||
|
||||
## 0.7.0 - 2026-01-17
|
||||
|
||||
### Features
|
||||
- `baoyu-comic`: adds `--aspect` (3:4, 4:3, 16:9) and `--lang` options; introduces multi-variant storyboard workflow (chronological, thematic, character-centric) with user selection.
|
||||
|
||||
### Enhancements
|
||||
- `baoyu-comic`: adds `analysis-framework.md` and `storyboard-template.md` for structured content analysis and variant generation.
|
||||
- `baoyu-slide-deck`: adds `analysis-framework.md`, `content-rules.md`, `modification-guide.md`, and `outline-template.md` references for improved outline quality.
|
||||
- `baoyu-article-illustrator`, `baoyu-cover-image`, `baoyu-xhs-images`: enhanced SKILL.md documentation with clearer workflows.
|
||||
|
||||
### Documentation
|
||||
- Multiple skills: restructured SKILL.md files—moved detailed content to `references/` directory for maintainability.
|
||||
- `baoyu-slide-deck`: simplified SKILL.md, consolidated style descriptions.
|
||||
|
||||
## 0.6.1 - 2026-01-17
|
||||
|
||||
- `baoyu-slide-deck`: adds `scripts/merge-to-pdf.ts` to export generated slides into a single PDF; docs updated with pptx/pdf outputs.
|
||||
- `baoyu-comic`: adds `scripts/merge-to-pdf.ts` to merge cover/pages into a PDF; docs clarify character reference handling (image vs text).
|
||||
- Docs conventions: adds a “Script Directory” template to `CLAUDE.md`; aligns `baoyu-gemini-web` / `baoyu-slide-deck` / `baoyu-comic` docs to use `${SKILL_DIR}` in commands so agents can run scripts from any install location.
|
||||
|
||||
## 0.6.0 - 2026-01-17
|
||||
|
||||
- `baoyu-slide-deck`: adds `scripts/merge-to-pptx.ts` to merge slide images into a PPTX and attach `prompts/` content as speaker notes.
|
||||
- `baoyu-slide-deck`: reshapes/expands the style library (adds `blueprint` / `bold-editorial` / `sketch-notes` / `vector-illustration`, and adjusts/replaces some older styles).
|
||||
- `baoyu-comic`: adds a `realistic` style reference.
|
||||
- Docs: refreshes `README.md` / `README.zh.md`.
|
||||
|
||||
## 0.5.3 - 2026-01-17
|
||||
|
||||
- `baoyu-post-to-x` (X Articles): makes image placeholder replacement more reliable (selection retry + verification; deletes via Backspace and verifies deletion before pasting), reducing mis-insertions/failures.
|
||||
|
||||
## 0.5.2 - 2026-01-16
|
||||
|
||||
- `baoyu-gemini-web`: adds `--sessionId` (local persisted sessions, plus `--list-sessions`) for multi-turn conversations and consistent multi-image generation.
|
||||
- `baoyu-gemini-web`: adds `--reference/--ref` for reference images (vision input), plus stronger timeout handling and cookie refresh recovery.
|
||||
- Docs: `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic` document session usage (reuse one `sessionId` per set) to improve visual consistency.
|
||||
|
||||
## 0.5.1 - 2026-01-16
|
||||
|
||||
- `baoyu-comic`: adds creation templates/references (character template, Ohmsha guide, outline template) to speed up “characters → storyboard → generation”.
|
||||
|
||||
## 0.5.0 - 2026-01-16
|
||||
|
||||
- Adds `baoyu-comic`: a knowledge-comic generator with `style × layout` and a full set of style/layout references for more stable output.
|
||||
- `baoyu-xhs-images`: moves style/layout details into `references/styles/*` and `references/layouts/*`, and migrates the base prompt into `references/base-prompt.md` for easier maintenance/reuse.
|
||||
- `baoyu-slide-deck` / `baoyu-cover-image`: similarly split base prompt and style references into `references/`, reducing SKILL.md complexity and making style expansion easier.
|
||||
- Docs: updates `README.md` / `README.zh.md` skill list and examples.
|
||||
|
||||
## 0.4.2 - 2026-01-15
|
||||
|
||||
- `baoyu-gemini-web`: updates description to clarify it as the image-generation backend for other skills (e.g. `cover-image`, `xhs-images`, `article-illustrator`).
|
||||
|
||||
## 0.4.1 - 2026-01-15
|
||||
|
||||
- `baoyu-post-to-x` / `baoyu-post-to-wechat`: adds `scripts/paste-from-clipboard.ts` to send a “real paste” keystroke (Cmd/Ctrl+V), avoiding sites ignoring CDP synthetic events.
|
||||
- `baoyu-post-to-x`: adds docs for X Articles/regular posts, and switches image upload to prefer real paste (with a CDP fallback).
|
||||
- `baoyu-post-to-wechat`: docs add script-location guidance and `${SKILL_DIR}` path usage for reliable agent execution.
|
||||
- Docs: adds `screenshots/update-plugins.png` for the marketplace update flow.
|
||||
|
||||
## 0.4.0 - 2026-01-15
|
||||
|
||||
- Adds `baoyu-` prefix to skill directories and updates marketplace paths/docs accordingly to reduce naming collisions.
|
||||
|
||||
## 0.3.1 - 2026-01-15
|
||||
|
||||
- `xhs-images`: upgrades docs to a Style × Layout system (adds `--layout`, auto layout selection, and a `notion` style), with more complete usage examples.
|
||||
- `article-illustrator` / `cover-image`: docs no longer hard-code `gemini-web`; instead they instruct the agent to pick an available image-generation skill.
|
||||
- `slide-deck`: docs add the `notion` style and update auto-style mapping.
|
||||
- Tooling/docs: adds `.DS_Store` to `.gitignore`; refreshes `README.md` / `README.zh.md`.
|
||||
|
||||
## 0.3.0 - 2026-01-14
|
||||
|
||||
- Adds `post-to-wechat`: Chrome CDP automation for WeChat Official Account posting (image-text + full article), including Markdown → WeChat HTML conversion and multiple themes.
|
||||
- Adds `CLAUDE.md`: repository structure, running conventions, and “add new skill” guidelines.
|
||||
- Docs: updates `README.md` / `README.zh.md` install/update/usage instructions.
|
||||
|
||||
## 0.2.0 - 2026-01-13
|
||||
|
||||
- Adds new skills: `post-to-x` (real Chrome/CDP automation for posts and X Articles), `article-illustrator`, `cover-image`, and `slide-deck`.
|
||||
- `xhs-images`: adds multi-style support (`--style`) with auto style selection and updates the base prompt (e.g. language follows input, hand-drawn infographic constraints).
|
||||
- Docs: adds `README.zh.md` and improves `README.md` and `.gitignore`.
|
||||
|
||||
## 0.1.1 - 2026-01-13
|
||||
|
||||
- Marketplace refactor: introduces `metadata` (including `version`), renames the plugin entry to `content-skills` and explicitly lists installable skills; removes legacy `.claude-plugin/plugin.json`.
|
||||
- Adds `xhs-images`: Xiaohongshu infographic series generator (outline + per-image prompts).
|
||||
- `gemini-web`: adds `--promptfiles` to build prompts from multiple files (system/content separation).
|
||||
- Docs: adds `README.md`.
|
||||
|
||||
## 0.1.0 - 2026-01-13
|
||||
|
||||
- Initial release: `.claude-plugin/marketplace.json` plus `gemini-web` (text/image generation, browser login + cookie cache).
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# Changelog
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 0.8.1 - 2026-01-17
|
||||
|
||||
### 重构
|
||||
- `baoyu-gemini-web`:重构脚本架构——将 10 个分散的脚本文件整合为结构化的 `gemini-webapi/` 模块(gemini_webapi Python 库的 TypeScript 移植版)。
|
||||
|
||||
## 0.8.0 - 2026-01-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-xhs-images`:新增内容分析框架(`analysis-framework.md`、`outline-template.md`),提供结构化内容拆解与大纲生成方案。
|
||||
|
||||
### 文档
|
||||
- `CLAUDE.md`:新增 Output Path Convention(目录结构、备份规则)和 Image Naming Convention(文件命名格式、slug 规则),统一图片生成输出规范。
|
||||
- 多个技能:更新文件管理规范,采用统一目录结构(`[source-name-no-ext]/<skill-suffix>/`)。
|
||||
- `baoyu-article-illustrator`、`baoyu-comic`、`baoyu-cover-image`、`baoyu-slide-deck`、`baoyu-xhs-images`
|
||||
|
||||
## 0.7.0 - 2026-01-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-comic`:新增 `--aspect`(3:4、4:3、16:9)和 `--lang` 选项;引入多变体分镜工作流(时间线、主题、人物视角),支持用户选择最佳方案。
|
||||
|
||||
### 增强
|
||||
- `baoyu-comic`:新增 `analysis-framework.md` 和 `storyboard-template.md`,提供结构化内容分析与变体生成框架。
|
||||
- `baoyu-slide-deck`:新增 `analysis-framework.md`、`content-rules.md`、`modification-guide.md`、`outline-template.md` 参考文档,提升大纲质量。
|
||||
- `baoyu-article-illustrator`、`baoyu-cover-image`、`baoyu-xhs-images`:SKILL.md 文档增强,工作流程更清晰。
|
||||
|
||||
### 文档
|
||||
- 多个技能:重构 SKILL.md 结构,将详细内容移至 `references/` 目录,便于维护。
|
||||
- `baoyu-slide-deck`:精简 SKILL.md,整合风格描述。
|
||||
|
||||
## 0.6.1 - 2026-01-17
|
||||
|
||||
- `baoyu-slide-deck`:新增 `scripts/merge-to-pdf.ts`,可将生成的 slide 图片一键合并为 PDF;文档补充导出步骤与产物命名(pptx/pdf)。
|
||||
- `baoyu-comic`:新增 `scripts/merge-to-pdf.ts`,将封面/分页图片合并为 PDF;补充角色参考(图片/文本)处理说明。
|
||||
- 文档规范:在 `CLAUDE.md` 中补充“Script Directory”模板;`baoyu-gemini-web` / `baoyu-slide-deck` / `baoyu-comic` 文档统一用 `${SKILL_DIR}` 引用脚本路径,方便 agent 在任意安装目录运行。
|
||||
|
||||
## 0.6.0 - 2026-01-17
|
||||
|
||||
- `baoyu-slide-deck`:新增 `scripts/merge-to-pptx.ts`,将生成的 slide 图片合并为 PPTX,并可把 `prompts/` 写入 speaker notes。
|
||||
- `baoyu-slide-deck`:风格库重组与扩充(新增 `blueprint` / `bold-editorial` / `sketch-notes` / `vector-illustration`,并调整/替换部分旧风格定义)。
|
||||
- `baoyu-comic`:新增 `realistic` 风格参考文件。
|
||||
- 文档:README / README.zh 同步更新技能说明与用法示例。
|
||||
|
||||
## 0.5.3 - 2026-01-17
|
||||
|
||||
- `baoyu-post-to-x`(X Articles):插图占位符替换更稳定——选中占位符增加重试与校验,改用 Backspace 删除并确认删除后再粘贴图片,降低插图错位/替换失败概率。
|
||||
|
||||
## 0.5.2 - 2026-01-16
|
||||
|
||||
- `baoyu-gemini-web`:新增 `--sessionId`(本地持久化会话,支持 `--list-sessions`),用于多轮对话/多图生成保持上下文一致。
|
||||
- `baoyu-gemini-web`:新增 `--reference/--ref` 传入参考图片(vision 输入),并增强超时与 cookie 失效自动恢复逻辑。
|
||||
- `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic`:文档补充 session 约定(整套图使用同一 `sessionId`,增强风格一致性)。
|
||||
|
||||
## 0.5.1 - 2026-01-16
|
||||
|
||||
- `baoyu-comic`:补齐创作模板与参考(角色模板、Ohmsha 教学漫画指南、大纲模板),更适合从“设定 → 分镜 → 生成”快速落地。
|
||||
|
||||
## 0.5.0 - 2026-01-16
|
||||
|
||||
- 新增 `baoyu-comic`:知识漫画生成器,支持 `style × layout` 组合,并提供风格/布局参考文件用于稳定出图。
|
||||
- `baoyu-xhs-images`:将 Style/Layout 的细节从 SKILL.md 拆分到 `references/styles/*` 与 `references/layouts/*`,并将基础提示词迁移到 `references/base-prompt.md`,便于维护和复用。
|
||||
- `baoyu-slide-deck` / `baoyu-cover-image`:同样将基础提示词与风格拆分到 `references/`,降低 SKILL.md 复杂度,便于扩展更多风格。
|
||||
- 文档:README / README.zh 更新技能清单与用法示例。
|
||||
|
||||
## 0.4.2 - 2026-01-15
|
||||
|
||||
- `baoyu-gemini-web`:描述信息更新,明确其作为 `cover-image` / `xhs-images` / `article-illustrator` 等技能的图片生成后端。
|
||||
|
||||
## 0.4.1 - 2026-01-15
|
||||
|
||||
- `baoyu-post-to-x` / `baoyu-post-to-wechat`:新增 `scripts/paste-from-clipboard.ts`,通过系统级 Cmd/Ctrl+V 发送“真实粘贴”按键,规避 CDP 合成事件在站点侧被忽略的问题。
|
||||
- `baoyu-post-to-x`:补充 X Articles/普通推文的操作文档(`references/articles.md`、`references/regular-posts.md`),并将发图流程改为优先使用“真实粘贴”(保留 CDP 兜底)。
|
||||
- `baoyu-post-to-wechat`:文档补充脚本目录说明与 `${SKILL_DIR}` 路径写法,便于 agent 可靠定位脚本。
|
||||
- 文档:新增插件更新流程截图 `screenshots/update-plugins.png`。
|
||||
|
||||
## 0.4.0 - 2026-01-15
|
||||
|
||||
- 技能命名统一加 `baoyu-` 前缀:目录结构、marketplace 清单与文档示例命令同步更新,减少与其它插件技能的命名冲突。
|
||||
|
||||
## 0.3.1 - 2026-01-15
|
||||
|
||||
- `xhs-images`:升级为 Style × Layout 二维系统(新增 `--layout`、自动布局选择与 Notion 风格),文档示例更完整。
|
||||
- `article-illustrator` / `slide-deck` / `cover-image`:文档改为“选择可用的图片生成技能”而非强绑定 `gemini-web`,并补充 Notion 风格相关说明。
|
||||
- 工程化:`.gitignore` 增加 `.DS_Store` 忽略;README / README.zh 同步调整。
|
||||
|
||||
## 0.3.0 - 2026-01-14
|
||||
|
||||
- 新增 `post-to-wechat`:基于 Chrome CDP 自动化发布公众号图文/文章,包含 Markdown → 微信 HTML 转换与多主题样式支持。
|
||||
- 新增 `CLAUDE.md`:补充仓库结构、运行方式与添加新技能的约定,方便协作与二次开发。
|
||||
- 文档:README / README.zh 更新安装、更新与使用说明。
|
||||
|
||||
## 0.2.0 - 2026-01-13
|
||||
|
||||
- 新增技能:`post-to-x`(真实 Chrome/CDP 自动化发布推文与 X Articles)、`article-illustrator`(文章智能插图规划)、`cover-image`(文章封面图生成)、`slide-deck`(幻灯片大纲与图片生成)。
|
||||
- `xhs-images`:新增 `--style` 多风格与自动风格选择,并更新基础提示词(例如语言随内容、强调手绘信息图等)。
|
||||
- 文档:新增 `README.zh.md`,并完善 README 与 `.gitignore`。
|
||||
|
||||
## 0.1.1 - 2026-01-13
|
||||
|
||||
- marketplace 结构重构:引入 `metadata`(含 `version`),插件名调整为 `content-skills` 并显式列出可安装 skills;移除旧 `.claude-plugin/plugin.json`。
|
||||
- 新增 `xhs-images`:小红书信息图系列生成技能(拆解内容、生成 outline 与提示词)。
|
||||
- `gemini-web`:新增 `--promptfiles`,支持从多个文件拼接 prompt(便于 system/content 分离)。
|
||||
- 文档:新增 `README.md`。
|
||||
|
||||
## 0.1.0 - 2026-01-13
|
||||
|
||||
- 初始发布:提供 `.claude-plugin/marketplace.json` 与 `gemini-web`(文本/图片生成、cookie 登录与缓存流程)。
|
||||
@@ -71,6 +71,30 @@ npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md conte
|
||||
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
|
||||
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
||||
4. Register in `marketplace.json` plugins[0].skills array as `./skills/baoyu-<name>`
|
||||
5. **Add Script Directory section** to SKILL.md (see template below)
|
||||
|
||||
### Script Directory Template
|
||||
|
||||
Every SKILL.md with scripts MUST include this section after Usage:
|
||||
|
||||
```markdown
|
||||
## Script Directory
|
||||
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/main.ts` | Main entry point |
|
||||
| `scripts/other.ts` | Other functionality |
|
||||
```
|
||||
|
||||
When referencing scripts in workflow sections, use `${SKILL_DIR}/scripts/<name>.ts` so agents can resolve the correct path.
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -78,3 +102,89 @@ npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md conte
|
||||
- Async/await patterns
|
||||
- Short variable names
|
||||
- Type-safe interfaces
|
||||
|
||||
## Image Generation Guidelines
|
||||
|
||||
Skills that require image generation MUST delegate to available image generation skills rather than implementing their own.
|
||||
|
||||
### Image Generation Skill Selection
|
||||
|
||||
1. Check available image generation skills in `skills/` directory
|
||||
2. Read each skill's SKILL.md to understand parameters and capabilities
|
||||
3. If multiple image generation skills available, ask user to choose preferred skill
|
||||
|
||||
### Generation Flow Template
|
||||
|
||||
Use this template when implementing image generation in skills:
|
||||
|
||||
```markdown
|
||||
### Step N: Generate Images
|
||||
|
||||
**Skill Selection**:
|
||||
1. Check available image generation skills (e.g., `baoyu-gemini-web`)
|
||||
2. Read selected skill's SKILL.md for parameter reference
|
||||
3. If multiple skills available, ask user to choose
|
||||
|
||||
**Generation Flow**:
|
||||
1. Call selected image generation skill with:
|
||||
- Prompt file path (or inline prompt)
|
||||
- Output image path
|
||||
- Any skill-specific parameters (refer to skill's SKILL.md)
|
||||
2. Generate images sequentially (one at a time)
|
||||
3. After each image, output progress: "Generated X/N"
|
||||
4. On failure, auto-retry once before reporting error
|
||||
```
|
||||
|
||||
### Output Path Convention
|
||||
|
||||
Generated images from the same skill and source file MUST be grouped together:
|
||||
|
||||
**With source file** (e.g., `/path/to/project/content/my-article.md`):
|
||||
```
|
||||
/path/to/project/content/my-article/<skill-suffix>/
|
||||
```
|
||||
- Remove file extension from source filename
|
||||
- Use skill name suffix (e.g., `xhs-images`, `cover-image`, `slide-deck`)
|
||||
- Example: source `/tests-data/anthropic-economic-index.md` + skill `baoyu-xhs-images` → `/tests-data/anthropic-economic-index/xhs-images/`
|
||||
|
||||
**Without source file**:
|
||||
```
|
||||
./<skill-suffix>/<source-slug>/
|
||||
```
|
||||
- Place under current project directory
|
||||
- Use descriptive slug for the content
|
||||
|
||||
**Directory Backup**:
|
||||
- If output directory already exists, rename existing directory with timestamp
|
||||
- Format: `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
- Example: `xhs-images` → `xhs-images-backup-20260117-143052`
|
||||
|
||||
### Image Naming Convention
|
||||
|
||||
Image filenames MUST include meaningful slugs for readability:
|
||||
|
||||
**Format**: `NN-{type}-[slug].png`
|
||||
- `NN`: Two-digit sequence number (01, 02, ...)
|
||||
- `{type}`: Image type (cover, content, page, slide, illustration, etc.)
|
||||
- `[slug]`: Descriptive kebab-case slug derived from content
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
01-cover-ai-future.png
|
||||
02-content-key-benefits.png
|
||||
03-page-enigma-machine.png
|
||||
04-slide-architecture-overview.png
|
||||
```
|
||||
|
||||
**Slug Rules**:
|
||||
- Derived from image purpose or content (kebab-case)
|
||||
- Must be unique within the output directory
|
||||
- 2-5 words, concise but descriptive
|
||||
- When content changes significantly, update slug accordingly
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Always read the image generation skill's SKILL.md before calling
|
||||
- Pass parameters exactly as documented in the skill
|
||||
- Handle failures gracefully with retry logic
|
||||
- Provide clear progress feedback to user
|
||||
|
||||
@@ -11,6 +11,12 @@ Skills shared by Baoyu for improving daily work efficiency with Claude Code.
|
||||
|
||||
## Installation
|
||||
|
||||
### Quick Install (Recommended)
|
||||
|
||||
```bash
|
||||
npx add-skill jimliu/baoyu-skills
|
||||
```
|
||||
|
||||
### Register as Plugin Marketplace
|
||||
|
||||
Run the following command in Claude Code:
|
||||
@@ -55,43 +61,43 @@ You can also **Enable auto-update** to get the latest versions automatically.
|
||||
|
||||
## Available Skills
|
||||
|
||||
### gemini-web
|
||||
### baoyu-gemini-web
|
||||
|
||||
Interacts with Gemini Web to generate text and images.
|
||||
|
||||
**Text Generation:**
|
||||
|
||||
```bash
|
||||
/gemini-web "Hello, Gemini"
|
||||
/gemini-web --prompt "Explain quantum computing"
|
||||
/baoyu-gemini-web "Hello, Gemini"
|
||||
/baoyu-gemini-web --prompt "Explain quantum computing"
|
||||
```
|
||||
|
||||
**Image Generation:**
|
||||
|
||||
```bash
|
||||
/gemini-web --prompt "A cute cat" --image cat.png
|
||||
/gemini-web --promptfiles system.md content.md --image out.png
|
||||
/baoyu-gemini-web --prompt "A cute cat" --image cat.png
|
||||
/baoyu-gemini-web --promptfiles system.md content.md --image out.png
|
||||
```
|
||||
|
||||
### xhs-images
|
||||
### baoyu-xhs-images
|
||||
|
||||
Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics with **Style × Layout** two-dimensional system.
|
||||
|
||||
```bash
|
||||
# Auto-select style and layout
|
||||
/xhs-images posts/ai-future/article.md
|
||||
/baoyu-xhs-images posts/ai-future/article.md
|
||||
|
||||
# Specify style
|
||||
/xhs-images posts/ai-future/article.md --style notion
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion
|
||||
|
||||
# Specify layout
|
||||
/xhs-images posts/ai-future/article.md --layout dense
|
||||
/baoyu-xhs-images posts/ai-future/article.md --layout dense
|
||||
|
||||
# Combine style and layout
|
||||
/xhs-images posts/ai-future/article.md --style tech --layout list
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style tech --layout list
|
||||
|
||||
# Direct content input
|
||||
/xhs-images 今日星座运势
|
||||
/baoyu-xhs-images 今日星座运势
|
||||
```
|
||||
|
||||
**Styles** (visual aesthetics): `cute` (default), `fresh`, `tech`, `warm`, `bold`, `minimal`, `retro`, `pop`, `notion`
|
||||
@@ -106,70 +112,142 @@ Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-1
|
||||
| `comparison` | 2 sides | Before/after, pros/cons |
|
||||
| `flow` | 3-6 steps | Processes, timelines |
|
||||
|
||||
### cover-image
|
||||
### baoyu-cover-image
|
||||
|
||||
Generate hand-drawn style cover images for articles with multiple style options.
|
||||
|
||||
```bash
|
||||
# From markdown file (auto-select style)
|
||||
/cover-image path/to/article.md
|
||||
/baoyu-cover-image path/to/article.md
|
||||
|
||||
# Specify a style
|
||||
/cover-image path/to/article.md --style tech
|
||||
/cover-image path/to/article.md --style warm
|
||||
/baoyu-cover-image path/to/article.md --style tech
|
||||
/baoyu-cover-image path/to/article.md --style warm
|
||||
|
||||
# Without title text
|
||||
/cover-image path/to/article.md --no-title
|
||||
/baoyu-cover-image path/to/article.md --no-title
|
||||
```
|
||||
|
||||
Available styles: `elegant` (default), `tech`, `warm`, `bold`, `minimal`, `playful`, `nature`, `retro`
|
||||
|
||||
### slide-deck
|
||||
### baoyu-slide-deck
|
||||
|
||||
Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images.
|
||||
|
||||
```bash
|
||||
# From markdown file
|
||||
/slide-deck path/to/article.md
|
||||
/baoyu-slide-deck path/to/article.md
|
||||
|
||||
# With style and audience
|
||||
/slide-deck path/to/article.md --style corporate
|
||||
/slide-deck path/to/article.md --audience executives
|
||||
/baoyu-slide-deck path/to/article.md --style corporate
|
||||
/baoyu-slide-deck path/to/article.md --audience executives
|
||||
|
||||
# Outline only (no image generation)
|
||||
/slide-deck path/to/article.md --outline-only
|
||||
/baoyu-slide-deck path/to/article.md --outline-only
|
||||
|
||||
# With language
|
||||
/slide-deck path/to/article.md --lang zh
|
||||
/baoyu-slide-deck path/to/article.md --lang zh
|
||||
```
|
||||
|
||||
Available styles: `editorial` (default), `corporate`, `technical`, `playful`, `minimal`, `storytelling`, `warm`, `retro-flat`, `notion`
|
||||
**Styles** (visual aesthetics):
|
||||
|
||||
### post-to-wechat
|
||||
| Style | Description | Best For |
|
||||
|-------|-------------|----------|
|
||||
| `notion` (default) | SaaS dashboard aesthetic with clean data focus, card-based layouts | Product demos, SaaS, productivity tools, B2B |
|
||||
| `sketch-notes` | Hand-drawn feel with soft brush strokes, warm off-white background | Educational, tutorials, knowledge sharing |
|
||||
| `blueprint` | Technical schematics with grid texture, engineering precision | Architecture, system design, data analysis |
|
||||
| `bold-editorial` | High-impact magazine style, bold typography, dark backgrounds | Product launches, marketing, keynotes |
|
||||
| `vector-illustration` | Flat vector with black outlines, retro soft colors, toy model aesthetic | Creative proposals, children's content, explainers |
|
||||
| `minimal` | Ultra-clean with maximum whitespace, single accent color, zen-like | Executive briefings, keynotes, premium brands |
|
||||
| `storytelling` | Cinematic full-bleed visuals, emotional photography | Case studies, narratives, customer journeys |
|
||||
| `warm` | Soft gradients, rounded shapes, wellness palette | Lifestyle, wellness, personal development |
|
||||
| `corporate` | Navy/gold palette, structured layouts, professional iconography | Investor decks, client proposals, quarterly reports |
|
||||
| `playful` | Vibrant coral/teal/yellow, rounded shapes, dynamic layouts | Workshops, training, creative pitches |
|
||||
|
||||
After generation, slides are automatically merged into a `.pptx` file for easy sharing.
|
||||
|
||||
### baoyu-comic
|
||||
|
||||
Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation.
|
||||
|
||||
```bash
|
||||
# From source material
|
||||
/baoyu-comic posts/turing-story/source.md
|
||||
|
||||
# Specify style
|
||||
/baoyu-comic posts/turing-story/source.md --style dramatic
|
||||
/baoyu-comic posts/turing-story/source.md --style ohmsha
|
||||
|
||||
# Custom style (natural language)
|
||||
/baoyu-comic posts/turing-story/source.md --style "watercolor with soft edges"
|
||||
|
||||
# Specify layout and aspect ratio
|
||||
/baoyu-comic posts/turing-story/source.md --layout cinematic
|
||||
/baoyu-comic posts/turing-story/source.md --aspect 16:9
|
||||
|
||||
# Specify language
|
||||
/baoyu-comic posts/turing-story/source.md --lang zh
|
||||
|
||||
# Direct content input
|
||||
/baoyu-comic "The story of Alan Turing and the birth of computer science"
|
||||
```
|
||||
|
||||
**Options**:
|
||||
| Option | Values |
|
||||
|--------|--------|
|
||||
| `--style` | `classic` (default), `dramatic`, `warm`, `tech`, `sepia`, `vibrant`, `ohmsha`, `realistic`, or custom description |
|
||||
| `--layout` | `standard` (default), `cinematic`, `dense`, `splash`, `mixed`, `webtoon` |
|
||||
| `--aspect` | `3:4` (default, portrait), `4:3` (landscape), `16:9` (widescreen) |
|
||||
| `--lang` | `auto` (default), `zh`, `en`, `ja`, etc. |
|
||||
|
||||
**Styles** (visual aesthetics):
|
||||
|
||||
| Style | Description | Best For |
|
||||
|-------|-------------|----------|
|
||||
| `classic` (default) | Traditional Ligne Claire with clean uniform outlines, flat colors, detailed backgrounds | Biographies, balanced narratives, educational content |
|
||||
| `dramatic` | High contrast with heavy shadows, intense expressions, angular compositions | Pivotal discoveries, conflicts, climactic scenes |
|
||||
| `warm` | Soft edges, golden tones, cozy interiors with nostalgic feel | Personal stories, childhood scenes, mentorship |
|
||||
| `tech` | Precise geometric lines, circuit motifs, neon accents on dark backgrounds | Computing history, AI stories, modern tech |
|
||||
| `sepia` | Vintage illustration style with aged paper effect, period-accurate details | Pre-1950s stories, classical science, historical figures |
|
||||
| `vibrant` | Energetic lines with weight variation, bright colors, dynamic poses | Science explanations, "aha" moments, young audience |
|
||||
| `ohmsha` | Manga guide style with visual metaphors, gadgets, student/mentor dynamic | Technical tutorials, complex concepts (ML, physics) |
|
||||
| `realistic` | Full-color realistic manga with digital painting, smooth gradients, accurate proportions | Wine, food, business, lifestyle, professional topics |
|
||||
|
||||
**Layouts** (panel arrangement):
|
||||
| Layout | Panels/Page | Best for |
|
||||
|--------|-------------|----------|
|
||||
| `standard` | 4-6 | Dialogue, narrative flow |
|
||||
| `cinematic` | 2-4 | Dramatic moments, establishing shots |
|
||||
| `dense` | 6-9 | Technical explanations, timelines |
|
||||
| `splash` | 1-2 large | Key moments, revelations |
|
||||
| `mixed` | 3-7 varies | Complex narratives, emotional arcs |
|
||||
| `webtoon` | 3-5 vertical | Ohmsha tutorials, mobile reading |
|
||||
|
||||
### baoyu-post-to-wechat
|
||||
|
||||
Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||
|
||||
**Image-Text (图文)** - Multiple images with short title/content:
|
||||
|
||||
```bash
|
||||
/post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
```
|
||||
|
||||
**Article (文章)** - Full markdown/HTML with rich formatting:
|
||||
|
||||
```bash
|
||||
/post-to-wechat 文章 --markdown article.md
|
||||
/post-to-wechat 文章 --markdown article.md --theme grace
|
||||
/post-to-wechat 文章 --html article.html
|
||||
/baoyu-post-to-wechat 文章 --markdown article.md
|
||||
/baoyu-post-to-wechat 文章 --markdown article.md --theme grace
|
||||
/baoyu-post-to-wechat 文章 --html article.html
|
||||
```
|
||||
|
||||
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
### gemini-web
|
||||
### baoyu-gemini-web
|
||||
|
||||
This skill uses the Gemini Web API (reverse-engineered).
|
||||
|
||||
|
||||
+109
-31
@@ -11,6 +11,12 @@
|
||||
|
||||
## 安装
|
||||
|
||||
### 快速安装(推荐)
|
||||
|
||||
```bash
|
||||
npx add-skill jimliu/baoyu-skills
|
||||
```
|
||||
|
||||
### 注册插件市场
|
||||
|
||||
在 Claude Code 中运行:
|
||||
@@ -55,43 +61,43 @@
|
||||
|
||||
## 可用技能
|
||||
|
||||
### gemini-web
|
||||
### baoyu-gemini-web
|
||||
|
||||
与 Gemini Web 交互,生成文本和图片。
|
||||
|
||||
**文本生成:**
|
||||
|
||||
```bash
|
||||
/gemini-web "你好,Gemini"
|
||||
/gemini-web --prompt "解释量子计算"
|
||||
/baoyu-gemini-web "你好,Gemini"
|
||||
/baoyu-gemini-web --prompt "解释量子计算"
|
||||
```
|
||||
|
||||
**图片生成:**
|
||||
|
||||
```bash
|
||||
/gemini-web --prompt "一只可爱的猫" --image cat.png
|
||||
/gemini-web --promptfiles system.md content.md --image out.png
|
||||
/baoyu-gemini-web --prompt "一只可爱的猫" --image cat.png
|
||||
/baoyu-gemini-web --promptfiles system.md content.md --image out.png
|
||||
```
|
||||
|
||||
### xhs-images
|
||||
### baoyu-xhs-images
|
||||
|
||||
小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图,支持 **风格 × 布局** 二维系统。
|
||||
|
||||
```bash
|
||||
# 自动选择风格和布局
|
||||
/xhs-images posts/ai-future/article.md
|
||||
/baoyu-xhs-images posts/ai-future/article.md
|
||||
|
||||
# 指定风格
|
||||
/xhs-images posts/ai-future/article.md --style notion
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion
|
||||
|
||||
# 指定布局
|
||||
/xhs-images posts/ai-future/article.md --layout dense
|
||||
/baoyu-xhs-images posts/ai-future/article.md --layout dense
|
||||
|
||||
# 组合风格和布局
|
||||
/xhs-images posts/ai-future/article.md --style tech --layout list
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style tech --layout list
|
||||
|
||||
# 直接输入内容
|
||||
/xhs-images 今日星座运势
|
||||
/baoyu-xhs-images 今日星座运势
|
||||
```
|
||||
|
||||
**风格**(视觉美学):`cute`(默认)、`fresh`、`tech`、`warm`、`bold`、`minimal`、`retro`、`pop`、`notion`
|
||||
@@ -106,70 +112,142 @@
|
||||
| `comparison` | 双栏 | 对比、优劣 |
|
||||
| `flow` | 3-6 步 | 流程、时间线 |
|
||||
|
||||
### cover-image
|
||||
### baoyu-cover-image
|
||||
|
||||
为文章生成手绘风格封面图,支持多种风格选项。
|
||||
|
||||
```bash
|
||||
# 从 markdown 文件生成(自动选择风格)
|
||||
/cover-image path/to/article.md
|
||||
/baoyu-cover-image path/to/article.md
|
||||
|
||||
# 指定风格
|
||||
/cover-image path/to/article.md --style tech
|
||||
/cover-image path/to/article.md --style warm
|
||||
/baoyu-cover-image path/to/article.md --style tech
|
||||
/baoyu-cover-image path/to/article.md --style warm
|
||||
|
||||
# 不包含标题文字
|
||||
/cover-image path/to/article.md --no-title
|
||||
/baoyu-cover-image path/to/article.md --no-title
|
||||
```
|
||||
|
||||
可用风格:`elegant`(默认)、`tech`、`warm`、`bold`、`minimal`、`playful`、`nature`、`retro`
|
||||
|
||||
### slide-deck
|
||||
### baoyu-slide-deck
|
||||
|
||||
从内容生成专业的幻灯片图片。先创建包含样式说明的完整大纲,然后逐页生成幻灯片图片。
|
||||
|
||||
```bash
|
||||
# 从 markdown 文件生成
|
||||
/slide-deck path/to/article.md
|
||||
/baoyu-slide-deck path/to/article.md
|
||||
|
||||
# 指定风格和受众
|
||||
/slide-deck path/to/article.md --style corporate
|
||||
/slide-deck path/to/article.md --audience executives
|
||||
/baoyu-slide-deck path/to/article.md --style corporate
|
||||
/baoyu-slide-deck path/to/article.md --audience executives
|
||||
|
||||
# 仅生成大纲(不生成图片)
|
||||
/slide-deck path/to/article.md --outline-only
|
||||
/baoyu-slide-deck path/to/article.md --outline-only
|
||||
|
||||
# 指定语言
|
||||
/slide-deck path/to/article.md --lang zh
|
||||
/baoyu-slide-deck path/to/article.md --lang zh
|
||||
```
|
||||
|
||||
可用风格:`editorial`(默认)、`corporate`、`technical`、`playful`、`minimal`、`storytelling`、`warm`、`retro-flat`、`notion`
|
||||
**风格**(视觉美学):
|
||||
|
||||
### post-to-wechat
|
||||
| 风格 | 描述 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `notion`(默认) | SaaS 仪表盘美学,卡片式布局,数据展示清晰 | 产品演示、SaaS、生产力工具、B2B |
|
||||
| `sketch-notes` | 手绘风格,柔和笔触,暖白色背景 | 教育、教程、知识分享 |
|
||||
| `blueprint` | 技术蓝图风格,网格纹理,工程精度 | 架构设计、系统设计、数据分析 |
|
||||
| `bold-editorial` | 杂志社论风格,粗体排版,深色背景,高冲击力 | 产品发布、营销、主题演讲 |
|
||||
| `vector-illustration` | 扁平矢量风格,黑色轮廓线,复古柔和配色,玩具模型感 | 创意提案、儿童内容、说明性内容 |
|
||||
| `minimal` | 极简风格,大量留白,单一强调色,禅意美学 | 高管简报、主题演讲、高端品牌 |
|
||||
| `storytelling` | 电影风格,全幅视觉,情感化摄影 | 案例研究、叙事、客户旅程 |
|
||||
| `warm` | 柔和渐变,圆润形状,健康生活配色 | 生活方式、健康养生、个人成长 |
|
||||
| `corporate` | 海军蓝/金色配色,结构化布局,专业图标 | 投资者演示、客户提案、季度报告 |
|
||||
| `playful` | 活力珊瑚/青色/黄色,圆润形状,动感布局 | 工作坊、培训、创意提案 |
|
||||
|
||||
生成完成后,所有幻灯片会自动合并为 `.pptx` 文件,方便分享。
|
||||
|
||||
### baoyu-comic
|
||||
|
||||
知识漫画创作器,支持多种风格(Logicomix/清线风格、欧姆社漫画教程风格)。创作带有详细分镜布局的原创教育漫画,逐页生成图片。
|
||||
|
||||
```bash
|
||||
# 从素材文件生成
|
||||
/baoyu-comic posts/turing-story/source.md
|
||||
|
||||
# 指定风格
|
||||
/baoyu-comic posts/turing-story/source.md --style dramatic
|
||||
/baoyu-comic posts/turing-story/source.md --style ohmsha
|
||||
|
||||
# 自定义风格(自然语言描述)
|
||||
/baoyu-comic posts/turing-story/source.md --style "水彩风格,边缘柔和"
|
||||
|
||||
# 指定布局和比例
|
||||
/baoyu-comic posts/turing-story/source.md --layout cinematic
|
||||
/baoyu-comic posts/turing-story/source.md --aspect 16:9
|
||||
|
||||
# 指定语言
|
||||
/baoyu-comic posts/turing-story/source.md --lang zh
|
||||
|
||||
# 直接输入内容
|
||||
/baoyu-comic "图灵的故事与计算机科学的诞生"
|
||||
```
|
||||
|
||||
**选项**:
|
||||
| 选项 | 取值 |
|
||||
|------|------|
|
||||
| `--style` | `classic`(默认)、`dramatic`、`warm`、`tech`、`sepia`、`vibrant`、`ohmsha`、`realistic`,或自然语言描述 |
|
||||
| `--layout` | `standard`(默认)、`cinematic`、`dense`、`splash`、`mixed`、`webtoon` |
|
||||
| `--aspect` | `3:4`(默认,竖版)、`4:3`(横版)、`16:9`(宽屏) |
|
||||
| `--lang` | `auto`(默认)、`zh`、`en`、`ja` 等 |
|
||||
|
||||
**风格**(视觉美学):
|
||||
|
||||
| 风格 | 描述 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `classic`(默认) | 传统清线风格,统一线条、平涂色彩、精细背景 | 传记、平衡叙事、教育内容 |
|
||||
| `dramatic` | 高对比度,重阴影、紧张表情、棱角分明的构图 | 重大发现、冲突、高潮场景 |
|
||||
| `warm` | 柔和边缘、金色调、温馨室内、怀旧感 | 个人故事、童年场景、师生情 |
|
||||
| `tech` | 精确几何线条、电路纹理、深色背景配霓虹色 | 计算机史、AI 故事、现代科技 |
|
||||
| `sepia` | 复古插画风格、做旧纸张效果、时代准确细节 | 1950 年前故事、古典科学、历史人物 |
|
||||
| `vibrant` | 富有活力的线条、明亮色彩、动感姿态 | 科学解说、"顿悟"时刻、青少年读者 |
|
||||
| `ohmsha` | 欧姆社漫画风格,视觉比喻、道具、学生/导师互动 | 技术教程、复杂概念(机器学习、物理) |
|
||||
| `realistic` | 全彩写实日漫风格,数字绘画、平滑渐变、准确人体比例 | 红酒、美食、商业、生活方式、专业话题 |
|
||||
|
||||
**布局**(分镜排列):
|
||||
| 布局 | 每页分镜数 | 适用场景 |
|
||||
|------|-----------|----------|
|
||||
| `standard` | 4-6 | 对话、叙事推进 |
|
||||
| `cinematic` | 2-4 | 戏剧性时刻、建立镜头 |
|
||||
| `dense` | 6-9 | 技术说明、时间线 |
|
||||
| `splash` | 1-2 大图 | 关键时刻、揭示 |
|
||||
| `mixed` | 3-7 不等 | 复杂叙事、情感弧线 |
|
||||
| `webtoon` | 3-5 竖向 | 欧姆社教程、手机阅读 |
|
||||
|
||||
### baoyu-post-to-wechat
|
||||
|
||||
发布内容到微信公众号,支持两种模式:
|
||||
|
||||
**图文模式** - 多图配短标题和正文:
|
||||
|
||||
```bash
|
||||
/post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
```
|
||||
|
||||
**文章模式** - 完整 markdown/HTML 富文本格式:
|
||||
|
||||
```bash
|
||||
/post-to-wechat 文章 --markdown article.md
|
||||
/post-to-wechat 文章 --markdown article.md --theme grace
|
||||
/post-to-wechat 文章 --html article.html
|
||||
/baoyu-post-to-wechat 文章 --markdown article.md
|
||||
/baoyu-post-to-wechat 文章 --markdown article.md --theme grace
|
||||
/baoyu-post-to-wechat 文章 --html article.html
|
||||
```
|
||||
|
||||
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||
|
||||
## 免责声明
|
||||
|
||||
### gemini-web
|
||||
### baoyu-gemini-web
|
||||
|
||||
此技能使用 Gemini Web API(逆向工程)。
|
||||
|
||||
|
||||
@@ -102,22 +102,44 @@ When no `--style` is specified, analyze content to select the best style:
|
||||
|
||||
## File Management
|
||||
|
||||
Save illustrations to `imgs/` subdirectory in the same folder as the article:
|
||||
### With Article Path
|
||||
|
||||
Save illustrations to `[source-name-no-ext]/illustrations/` subdirectory in the same folder as the article:
|
||||
|
||||
```
|
||||
path/to/
|
||||
├── article.md
|
||||
└── imgs/
|
||||
└── article/
|
||||
└── illustrations/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── illustration-concept-a.md
|
||||
│ ├── illustration-concept-b.md
|
||||
│ └── ...
|
||||
├── illustration-concept-a.png
|
||||
├── illustration-concept-b.png
|
||||
└── ...
|
||||
```
|
||||
|
||||
Example: `/posts/ai-future.md` → `/posts/ai-future/illustrations/`
|
||||
|
||||
### Without Article Path (Pasted Content)
|
||||
|
||||
Save to `./illustrations/[topic-slug]/`:
|
||||
|
||||
```
|
||||
illustrations/
|
||||
└── ai-future/
|
||||
├── source.md
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── illustration-concept-a.md
|
||||
│ ├── illustration-concept-b.md
|
||||
│ └── ...
|
||||
├── illustration-concept-a.png
|
||||
├── illustration-concept-b.png
|
||||
└── ...
|
||||
└── *.png
|
||||
```
|
||||
|
||||
### Directory Backup
|
||||
|
||||
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content & Select Style
|
||||
@@ -125,7 +147,11 @@ path/to/
|
||||
1. Read article content
|
||||
2. If `--style` specified, use that style
|
||||
3. Otherwise, scan for style signals and auto-select
|
||||
4. Extract key information:
|
||||
4. **Language detection**:
|
||||
- Detect **source language** from article content
|
||||
- Detect **user language** from conversation context
|
||||
- Note if source_language ≠ user_language (will ask in Step 4)
|
||||
5. Extract key information:
|
||||
- Main topic and themes
|
||||
- Core messages per section
|
||||
- Abstract concepts needing visualization
|
||||
@@ -173,10 +199,55 @@ path/to/
|
||||
...
|
||||
```
|
||||
|
||||
### Step 4: Create Prompt Files
|
||||
### Step 4: Review & Confirm
|
||||
|
||||
**Purpose**: Let user confirm all options in a single step before image generation.
|
||||
|
||||
**IMPORTANT**: Present ALL options in a single confirmation step using AskUserQuestion. Do NOT interrupt workflow with multiple separate confirmations.
|
||||
|
||||
1. **Generate 3 style variants**:
|
||||
- Analyze content to select 3 most suitable styles
|
||||
- Generate complete illustration plan for each style variant
|
||||
- Save as `outline-{style}.md` (e.g., `outline-notion.md`, `outline-tech.md`, `outline-warm.md`)
|
||||
|
||||
2. **Determine which questions to ask**:
|
||||
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Style variant | Always (required) |
|
||||
| Language | Only if `source_language ≠ user_language` |
|
||||
|
||||
3. **Present options** (use AskUserQuestion with all applicable questions):
|
||||
|
||||
**Question 1 (Style)** - always:
|
||||
- Style A (recommended): [style name] - [brief description]
|
||||
- Style B: [style name] - [brief description]
|
||||
- Style C: [style name] - [brief description]
|
||||
- Custom: Provide custom style reference
|
||||
|
||||
**Question 2 (Language)** - only if source ≠ user language:
|
||||
- [Source language] (matches article language)
|
||||
- [User language] (your preference)
|
||||
|
||||
**Language handling**:
|
||||
- If source language = user language: Just inform user (e.g., "Prompts will be in Chinese")
|
||||
- If different: Ask which language to use for prompts
|
||||
|
||||
4. **Apply selection**:
|
||||
- Copy selected `outline-{style}.md` to `outline.md`
|
||||
- If custom style provided, generate new plan with that style
|
||||
- If different language selected, regenerate outline in that language
|
||||
- User may edit `outline.md` directly for fine-tuning
|
||||
- If modified, reload plan before proceeding
|
||||
|
||||
5. **Proceed only after explicit user confirmation**
|
||||
|
||||
### Step 5: Create Prompt Files
|
||||
|
||||
Save prompts to `prompts/` directory with style-specific details.
|
||||
|
||||
**All prompts are written in the user's confirmed language preference.**
|
||||
|
||||
**Prompt Format**:
|
||||
|
||||
```markdown
|
||||
@@ -199,7 +270,7 @@ Text content (if any):
|
||||
Style notes: [specific style characteristics]
|
||||
```
|
||||
|
||||
### Step 5: Generate Images
|
||||
### Step 6: Generate Images
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
@@ -212,12 +283,12 @@ Style notes: [specific style characteristics]
|
||||
4. On failure, auto-retry once
|
||||
5. If retry fails, log reason, continue to next
|
||||
|
||||
### Step 6: Update Article
|
||||
### Step 7: Update Article
|
||||
|
||||
Insert generated images at corresponding positions:
|
||||
|
||||
```markdown
|
||||

|
||||

|
||||
```
|
||||
|
||||
**Insertion Rules**:
|
||||
@@ -225,7 +296,7 @@ Insert generated images at corresponding positions:
|
||||
- Leave one blank line before and after image
|
||||
- Alt text uses concise description in article's language
|
||||
|
||||
### Step 7: Output Summary
|
||||
### Step 8: Output Summary
|
||||
|
||||
```
|
||||
Article Illustration Complete!
|
||||
@@ -244,6 +315,57 @@ Failed:
|
||||
- illustration-zzz.png: [failure reason]
|
||||
```
|
||||
|
||||
## Illustration Modification
|
||||
|
||||
Support for modifying individual illustrations after initial generation.
|
||||
|
||||
### Edit Single Illustration
|
||||
|
||||
Regenerate a specific illustration with modified prompt:
|
||||
|
||||
1. Identify illustration to edit (e.g., `illustration-concept-overview.png`)
|
||||
2. Update prompt in `prompts/illustration-concept-overview.md` if needed
|
||||
3. If content changes significantly, update slug in filename
|
||||
4. Regenerate image
|
||||
5. Update article if image reference changed
|
||||
|
||||
### Add New Illustration
|
||||
|
||||
Add a new illustration to the article:
|
||||
|
||||
1. Identify insertion position in article
|
||||
2. Create new prompt with appropriate slug (e.g., `illustration-new-concept.md`)
|
||||
3. Generate new illustration image
|
||||
4. Update `outline.md` with new illustration entry
|
||||
5. Insert image reference in article at the specified position
|
||||
|
||||
### Delete Illustration
|
||||
|
||||
Remove an illustration from the article:
|
||||
|
||||
1. Identify illustration to delete (e.g., `illustration-concept-overview.png`)
|
||||
2. Remove image file and prompt file
|
||||
3. Remove image reference from article
|
||||
4. Update `outline.md` to remove illustration entry
|
||||
|
||||
### File Naming Convention
|
||||
|
||||
Files use meaningful slugs for better readability:
|
||||
```
|
||||
illustration-[slug].png
|
||||
illustration-[slug].md (in prompts/)
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `illustration-concept-overview.png`
|
||||
- `illustration-workflow-diagram.png`
|
||||
- `illustration-key-benefits.png`
|
||||
|
||||
**Slug rules**:
|
||||
- Derived from illustration purpose/content (kebab-case)
|
||||
- Must be unique within the article
|
||||
- When content changes significantly, update slug accordingly
|
||||
|
||||
## Style Reference Details
|
||||
|
||||
### elegant
|
||||
@@ -325,4 +447,5 @@ Typography: Clean hand-drawn lettering, simple sans-serif labels
|
||||
- Maintain selected style consistency across all illustrations in one article
|
||||
- Image generation typically takes 10-30 seconds per image
|
||||
- Sensitive figures should use cartoon alternatives
|
||||
- Prompt and illustration text language should match article language
|
||||
- Prompts written in user's confirmed language preference
|
||||
- Illustration text (labels, captions) should match article language
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
---
|
||||
name: baoyu-comic
|
||||
description: Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
|
||||
---
|
||||
|
||||
# Knowledge Comic Creator
|
||||
|
||||
Create original knowledge comics with multiple visual styles.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/baoyu-comic posts/turing-story/source.md
|
||||
/baoyu-comic # then paste content
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Values |
|
||||
|--------|--------|
|
||||
| `--style` | classic (default), dramatic, warm, tech, sepia, vibrant, ohmsha, realistic, or custom description |
|
||||
| `--layout` | standard (default), cinematic, dense, splash, mixed, webtoon |
|
||||
| `--aspect` | 3:4 (default, portrait), 4:3 (landscape), 16:9 (widescreen) |
|
||||
| `--lang` | auto (default), zh, en, ja, etc. |
|
||||
|
||||
Style × Layout × Aspect can be freely combined. Custom styles can be described in natural language.
|
||||
|
||||
**Aspect ratio is consistent across all pages in a comic.**
|
||||
|
||||
## Auto Selection
|
||||
|
||||
| Content Signals | Style | Layout |
|
||||
|-----------------|-------|--------|
|
||||
| Tutorial, how-to, beginner | ohmsha | webtoon |
|
||||
| Computing, AI, programming | tech | dense |
|
||||
| Pre-1950, classical, ancient | sepia | cinematic |
|
||||
| Personal story, mentor | warm | standard |
|
||||
| Conflict, breakthrough | dramatic | splash |
|
||||
| Wine, food, business, lifestyle, professional | realistic | cinematic |
|
||||
| Biography, balanced | classic | mixed |
|
||||
|
||||
## Script Directory
|
||||
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/merge-to-pdf.ts` | Merge comic pages into PDF |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
[target]/
|
||||
├── source.md # Source content (if pasted, not file)
|
||||
├── analysis.md # Deep analysis results (YAML+MD)
|
||||
├── storyboard-chronological.md # Variant A (preserved)
|
||||
├── storyboard-thematic.md # Variant B (preserved)
|
||||
├── storyboard-character.md # Variant C (preserved)
|
||||
├── characters-chronological/ # Variant A chars (preserved)
|
||||
│ ├── characters.md
|
||||
│ └── characters.png
|
||||
├── characters-thematic/ # Variant B chars (preserved)
|
||||
│ ├── characters.md
|
||||
│ └── characters.png
|
||||
├── characters-character/ # Variant C chars (preserved)
|
||||
│ ├── characters.md
|
||||
│ └── characters.png
|
||||
├── storyboard.md # Final selected
|
||||
├── characters/ # Final selected
|
||||
│ ├── characters.md
|
||||
│ └── characters.png
|
||||
├── prompts/
|
||||
│ ├── 00-cover-[slug].md
|
||||
│ └── NN-page-[slug].md
|
||||
├── 00-cover-[slug].png
|
||||
├── NN-page-[slug].png
|
||||
└── {topic-slug}.pdf
|
||||
```
|
||||
|
||||
**Target directory**:
|
||||
- With source path: `[source-dir]/[source-name-no-ext]/comic/`
|
||||
- Example: `/posts/turing-story.md` → `/posts/turing-story/comic/`
|
||||
- Without source: `./comic/[topic-slug]/`
|
||||
|
||||
**Directory backup**:
|
||||
- If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content → `analysis.md`
|
||||
|
||||
Read source content, save it if needed, and perform deep analysis.
|
||||
|
||||
**Actions**:
|
||||
1. **Save source content** (if not already a file):
|
||||
- If user provides a file path: use as-is
|
||||
- If user pastes content: save to `source.md` in target directory
|
||||
2. Read source content
|
||||
3. **Deep analysis** following `references/analysis-framework.md`:
|
||||
- Target audience identification
|
||||
- Value proposition for readers
|
||||
- Core themes and narrative potential
|
||||
- Key figures and their story arcs
|
||||
4. Detect source language
|
||||
5. Determine recommended page count:
|
||||
- Short story: 5-8 pages
|
||||
- Medium complexity: 9-15 pages
|
||||
- Full biography: 16-25 pages
|
||||
6. Analyze content signals for style/layout recommendations
|
||||
7. **Save to `analysis.md`**
|
||||
|
||||
**analysis.md Format**:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Alan Turing: Father of Computing"
|
||||
topic: Biography
|
||||
time_span: 1912-1954
|
||||
source_language: en
|
||||
user_language: zh
|
||||
aspect_ratio: "3:4"
|
||||
recommended_page_count: 12
|
||||
---
|
||||
|
||||
## Target Audience
|
||||
|
||||
- **Primary**: Tech enthusiasts curious about computing history
|
||||
- **Secondary**: Students learning about scientific breakthroughs
|
||||
- **Tertiary**: General readers interested in biographical stories
|
||||
|
||||
## Value Proposition
|
||||
|
||||
What readers will gain:
|
||||
1. Understanding of how modern computing was born
|
||||
2. Emotional connection to a brilliant but tragic figure
|
||||
3. Appreciation for the human cost of innovation
|
||||
|
||||
## Core Themes
|
||||
|
||||
| Theme | Narrative Potential | Visual Opportunity |
|
||||
|-------|--------------------|--------------------|
|
||||
| Genius vs. Society | High conflict, dramatic arcs | Contrast scenes |
|
||||
| Code-breaking | Mystery, tension | Technical diagrams as art |
|
||||
| Personal tragedy | Emotional depth | Intimate, somber panels |
|
||||
|
||||
## Key Figures & Story Arcs
|
||||
|
||||
### Alan Turing (Protagonist)
|
||||
- **Arc**: Misunderstood genius → War hero → Tragic end
|
||||
- **Visual identity**: Disheveled academic, intense eyes
|
||||
- **Key moments**: Enigma breakthrough, arrest, final days
|
||||
|
||||
### Christopher Morcom (Catalyst)
|
||||
- **Role**: Early friend whose death shaped Turing
|
||||
- **Visual identity**: Youthful, bright
|
||||
- **Key moments**: School friendship, sudden death
|
||||
|
||||
## Content Signals
|
||||
|
||||
- "biography" → classic + mixed
|
||||
- "computing history" → tech + dense
|
||||
- "personal tragedy" → dramatic + splash
|
||||
|
||||
## Recommended Approaches
|
||||
|
||||
1. **Chronological** - follow life timeline (recommended for biography)
|
||||
2. **Thematic** - organize by contributions (good for educational focus)
|
||||
3. **Character-focused** - relationships drive narrative (good for emotional impact)
|
||||
```
|
||||
|
||||
### Step 2: Generate 3 Storyboard Variants
|
||||
|
||||
Create three distinct variants, each combining a narrative approach with a recommended style.
|
||||
|
||||
| Variant | Narrative Approach | Recommended Style | Layout |
|
||||
|---------|-------------------|-------------------|--------|
|
||||
| A | Chronological | sepia | cinematic |
|
||||
| B | Thematic | tech | dense |
|
||||
| C | Character-focused | warm | standard |
|
||||
|
||||
**For each variant**:
|
||||
|
||||
1. **Generate storyboard** (`storyboard-{approach}.md`):
|
||||
- YAML front matter with narrative_approach, recommended_style, recommended_layout, aspect_ratio
|
||||
- Cover design
|
||||
- Each page: layout, panel breakdown, visual prompts
|
||||
- **Written in user's preferred language**
|
||||
- Reference: `references/storyboard-template.md`
|
||||
|
||||
2. **Generate matching characters** (`characters-{approach}/`):
|
||||
- `characters.md` - visual specs matching the recommended style (in user's preferred language)
|
||||
- `characters.png` - character reference sheet
|
||||
- Reference: `references/character-template.md`
|
||||
|
||||
**All variants are preserved after selection for reference.**
|
||||
|
||||
### Step 3: User Confirms All Options
|
||||
|
||||
**IMPORTANT**: Present ALL options in a single confirmation step using AskUserQuestion. Do NOT interrupt workflow with multiple separate confirmations.
|
||||
|
||||
**Determine which questions to ask**:
|
||||
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Storyboard variant | Always (required) |
|
||||
| Visual style | Always (required) |
|
||||
| Language | Only if `source_language ≠ user_language` |
|
||||
| Aspect ratio | Only if user might prefer non-default (e.g., landscape content) |
|
||||
|
||||
**Language handling**:
|
||||
- If source language = user language: Just inform user (e.g., "Comic will be in Chinese")
|
||||
- If different: Ask which language to use
|
||||
|
||||
**All storyboards and prompts are generated in the user's selected/preferred language.**
|
||||
|
||||
**Aspect ratio handling**:
|
||||
- Default: 3:4 (portrait) - standard comic format
|
||||
- Offer 4:3 (landscape) if content suits it (e.g., panoramic scenes, technical diagrams)
|
||||
- Offer 16:9 (widescreen) for cinematic content
|
||||
|
||||
**AskUserQuestion format** (example with all questions):
|
||||
|
||||
```
|
||||
Question 1 (Storyboard): Which storyboard variant?
|
||||
- A: Chronological + sepia (Recommended)
|
||||
- B: Thematic + tech
|
||||
- C: Character-focused + warm
|
||||
- Custom
|
||||
|
||||
Question 2 (Style): Which visual style?
|
||||
- sepia (Recommended from variant)
|
||||
- classic / dramatic / warm / tech / vibrant / ohmsha / realistic
|
||||
- Custom description
|
||||
|
||||
Question 3 (Language) - only if mismatch:
|
||||
- Chinese (source material language)
|
||||
- English (your preference)
|
||||
|
||||
Question 4 (Aspect) - only if relevant:
|
||||
- 3:4 Portrait (Recommended)
|
||||
- 4:3 Landscape
|
||||
- 16:9 Widescreen
|
||||
```
|
||||
|
||||
**After confirmation**:
|
||||
1. Copy selected storyboard → `storyboard.md`
|
||||
2. Copy selected characters → `characters/`
|
||||
3. Update YAML front matter with confirmed style, language, aspect_ratio
|
||||
4. If style differs from variant's recommended: regenerate `characters/characters.png`
|
||||
5. User may edit files directly for fine-tuning
|
||||
|
||||
### Step 4: Generate Images
|
||||
|
||||
With confirmed storyboard + style + aspect ratio:
|
||||
|
||||
**For each page (cover + pages)**:
|
||||
1. Save prompt to `prompts/NN-{cover|page}-[slug].md` (in user's preferred language)
|
||||
2. Generate image using confirmed style and aspect ratio
|
||||
3. Report progress after each generation
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
- Check available image generation skills
|
||||
- If multiple skills available, ask user preference
|
||||
|
||||
**Character Reference Handling**:
|
||||
- If skill supports reference image: pass `characters/characters.png`
|
||||
- If skill does NOT support reference image: include `characters/characters.md` content in prompt
|
||||
|
||||
**Session Management**:
|
||||
If image generation skill supports `--sessionId`:
|
||||
1. Generate unique session ID: `comic-{topic-slug}-{timestamp}`
|
||||
2. Use same session ID for all pages
|
||||
3. Ensures visual consistency across generated images
|
||||
|
||||
### Step 5: Merge to PDF
|
||||
|
||||
After all images generated:
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/merge-to-pdf.ts <comic-dir>
|
||||
```
|
||||
|
||||
Creates `{topic-slug}.pdf` with all pages as full-page images.
|
||||
|
||||
### Step 6: Completion Report
|
||||
|
||||
```
|
||||
Comic Complete!
|
||||
Title: [title] | Style: [style] | Pages: [count] | Aspect: [ratio] | Language: [lang]
|
||||
Location: [path]
|
||||
✓ analysis.md
|
||||
✓ characters.png
|
||||
✓ 00-cover-[slug].png ... NN-page-[slug].png
|
||||
✓ {topic-slug}.pdf
|
||||
```
|
||||
|
||||
## Page Modification
|
||||
|
||||
Support for modifying individual pages after initial generation.
|
||||
|
||||
### Edit Single Page
|
||||
|
||||
Regenerate a specific page with modified prompt:
|
||||
|
||||
1. Identify page to edit (e.g., `03-page-enigma-machine.png`)
|
||||
2. Update prompt in `prompts/03-page-enigma-machine.md` if needed
|
||||
3. If content changes significantly, update slug in filename
|
||||
4. Regenerate image using same session ID and aspect ratio
|
||||
5. Regenerate PDF
|
||||
|
||||
### Add New Page
|
||||
|
||||
Insert a new page at specified position:
|
||||
|
||||
1. Specify insertion position (e.g., after page 3)
|
||||
2. Create new prompt with appropriate slug (e.g., `04-page-bletchley-park.md`)
|
||||
3. Generate new page image (same aspect ratio)
|
||||
4. **Renumber files**: All subsequent pages increment NN by 1
|
||||
- `04-page-tragedy.png` → `05-page-tragedy.png`
|
||||
- Slugs remain unchanged
|
||||
5. Update `storyboard.md` with new page entry
|
||||
6. Regenerate PDF
|
||||
|
||||
### Delete Page
|
||||
|
||||
Remove a page and renumber:
|
||||
|
||||
1. Identify page to delete (e.g., `03-page-enigma-machine.png`)
|
||||
2. Remove image file and prompt file
|
||||
3. **Renumber files**: All subsequent pages decrement NN by 1
|
||||
- `04-page-tragedy.png` → `03-page-tragedy.png`
|
||||
- Slugs remain unchanged
|
||||
4. Update `storyboard.md` to remove page entry
|
||||
5. Regenerate PDF
|
||||
|
||||
### File Naming Convention
|
||||
|
||||
Files use meaningful slugs for better readability:
|
||||
```
|
||||
NN-cover-[slug].png / NN-page-[slug].png
|
||||
NN-cover-[slug].md / NN-page-[slug].md (in prompts/)
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `00-cover-turing-story.png`
|
||||
- `01-page-early-life.png`
|
||||
- `02-page-cambridge-years.png`
|
||||
- `03-page-enigma-machine.png`
|
||||
|
||||
**Slug rules**:
|
||||
- Derived from page title/content (kebab-case)
|
||||
- Must be unique within the comic
|
||||
- When page content changes significantly, update slug accordingly
|
||||
|
||||
**Renumbering**:
|
||||
- After add/delete, update NN prefix for affected pages
|
||||
- Slug remains unchanged unless content changes
|
||||
- Maintain sequential numbering with no gaps
|
||||
|
||||
## Style-Specific Guidelines
|
||||
|
||||
### Ohmsha Style (`--style ohmsha`)
|
||||
|
||||
Additional requirements for educational manga:
|
||||
- **Default: Use Doraemon characters directly** - No need to create new characters
|
||||
- 大雄 (Nobita): Student role, curious learner
|
||||
- 哆啦A梦 (Doraemon): Mentor role, explains concepts with gadgets
|
||||
- 胖虎 (Gian): Antagonist/challenge role, represents obstacles or misconceptions
|
||||
- 静香 (Shizuka): Supporting role, asks clarifying questions
|
||||
- Custom characters only if explicitly requested: `--characters "Student:小明,Mentor:教授"`
|
||||
- Must use visual metaphors (gadgets, action scenes) - NO talking heads
|
||||
- Page titles: narrative style, not "Page X: Topic"
|
||||
|
||||
**Reference**: `references/ohmsha-guide.md` for detailed guidelines.
|
||||
|
||||
## References
|
||||
|
||||
Detailed templates and guidelines in `references/` directory:
|
||||
- `analysis-framework.md` - Deep content analysis for comic adaptation
|
||||
- `character-template.md` - Character definition format and examples
|
||||
- `storyboard-template.md` - Storyboard structure and panel breakdown
|
||||
- `ohmsha-guide.md` - Ohmsha manga style specifics
|
||||
- `styles/` - Detailed style definitions
|
||||
- `layouts/` - Detailed layout definitions
|
||||
@@ -0,0 +1,152 @@
|
||||
# Comic Content Analysis Framework
|
||||
|
||||
Deep analysis framework for transforming source content into effective visual storytelling.
|
||||
|
||||
## Purpose
|
||||
|
||||
Before creating a comic, thoroughly analyze the source material to:
|
||||
- Identify the target audience and their needs
|
||||
- Determine what value the comic will deliver
|
||||
- Extract narrative potential for visual storytelling
|
||||
- Plan character arcs and key moments
|
||||
|
||||
## Analysis Dimensions
|
||||
|
||||
### 1. Core Content (Understanding "What")
|
||||
|
||||
**Central Message**
|
||||
- What is the single most important idea readers should take away?
|
||||
- Can you express it in one sentence?
|
||||
|
||||
**Key Concepts**
|
||||
- What are the essential concepts readers must understand?
|
||||
- How should these concepts be visualized?
|
||||
- Which concepts need simplified explanations?
|
||||
|
||||
**Content Structure**
|
||||
- How is the source material organized?
|
||||
- What is the natural narrative arc?
|
||||
- Where are the climax and turning points?
|
||||
|
||||
**Evidence & Examples**
|
||||
- What concrete examples, data, or stories support the main ideas?
|
||||
- Which examples translate well to visual panels?
|
||||
- What can be shown rather than told?
|
||||
|
||||
### 2. Context & Background (Understanding "Why")
|
||||
|
||||
**Source Origin**
|
||||
- Who created this content? What is their perspective?
|
||||
- What was the original purpose?
|
||||
- Is there bias to be aware of?
|
||||
|
||||
**Historical/Cultural Context**
|
||||
- When and where does the story take place?
|
||||
- What background knowledge do readers need?
|
||||
- What period-specific visual elements are required?
|
||||
|
||||
**Underlying Assumptions**
|
||||
- What does the source assume readers already know?
|
||||
- What implicit beliefs or values are present?
|
||||
- Should the comic challenge or reinforce these?
|
||||
|
||||
### 3. Audience Analysis
|
||||
|
||||
**Primary Audience**
|
||||
- Who will read this comic?
|
||||
- What is their existing knowledge level?
|
||||
- What are their interests and motivations?
|
||||
|
||||
**Secondary Audiences**
|
||||
- Who else might benefit from this comic?
|
||||
- How might their needs differ?
|
||||
|
||||
**Reader Questions**
|
||||
- What questions will readers have?
|
||||
- What misconceptions might they bring?
|
||||
- What "aha moments" can we create?
|
||||
|
||||
### 4. Value Proposition
|
||||
|
||||
**Knowledge Value**
|
||||
- What will readers learn?
|
||||
- What new perspectives will they gain?
|
||||
- How will this change their understanding?
|
||||
|
||||
**Emotional Value**
|
||||
- What emotions should readers feel?
|
||||
- What connections will they make with characters?
|
||||
- What will make this memorable?
|
||||
|
||||
**Practical Value**
|
||||
- Can readers apply what they learn?
|
||||
- What actions might this inspire?
|
||||
- What conversations might it spark?
|
||||
|
||||
### 5. Narrative Potential
|
||||
|
||||
**Story Arc Candidates**
|
||||
- What natural narratives exist in the content?
|
||||
- Where is the conflict or tension?
|
||||
- What transformations occur?
|
||||
|
||||
**Character Potential**
|
||||
- Who are the key figures?
|
||||
- What are their motivations and obstacles?
|
||||
- How do they change throughout?
|
||||
|
||||
**Visual Opportunities**
|
||||
- What scenes have strong visual potential?
|
||||
- Where can abstract concepts become concrete images?
|
||||
- What metaphors can be visualized?
|
||||
|
||||
**Dramatic Moments**
|
||||
- What are the breakthrough/revelation moments?
|
||||
- Where are the emotional peaks?
|
||||
- What creates tension and release?
|
||||
|
||||
### 6. Adaptation Considerations
|
||||
|
||||
**What to Keep**
|
||||
- Essential facts and ideas
|
||||
- Key quotes or moments
|
||||
- Core emotional beats
|
||||
|
||||
**What to Simplify**
|
||||
- Complex explanations
|
||||
- Dense technical details
|
||||
- Lengthy descriptions
|
||||
|
||||
**What to Expand**
|
||||
- Brief mentions that deserve more attention
|
||||
- Implied emotions or relationships
|
||||
- Visual details not in source
|
||||
|
||||
**What to Omit**
|
||||
- Tangential information
|
||||
- Redundant examples
|
||||
- Content that doesn't serve the narrative
|
||||
|
||||
## Output Format
|
||||
|
||||
Analysis results should be saved to `analysis.md` with:
|
||||
|
||||
1. **YAML Front Matter**: Metadata (title, topic, time_span, languages, aspect_ratio, page_count)
|
||||
2. **Target Audience**: Primary, secondary, tertiary audiences with their needs
|
||||
3. **Value Proposition**: What readers will gain (knowledge, emotional, practical)
|
||||
4. **Core Themes**: Table with theme, narrative potential, visual opportunity
|
||||
5. **Key Figures & Story Arcs**: Character profiles with arcs, visual identity, key moments
|
||||
6. **Content Signals**: Style and layout recommendations based on content type
|
||||
7. **Recommended Approaches**: Narrative approaches ranked by suitability
|
||||
|
||||
## Analysis Checklist
|
||||
|
||||
Before proceeding to storyboard:
|
||||
|
||||
- [ ] Can I state the core message in one sentence?
|
||||
- [ ] Do I know exactly who will read this comic?
|
||||
- [ ] Have I identified at least 3 ways this comic provides value?
|
||||
- [ ] Are there clear protagonists with compelling arcs?
|
||||
- [ ] Have I found at least 5 visually powerful moments?
|
||||
- [ ] Do I understand what to keep, simplify, expand, and omit?
|
||||
- [ ] Have I identified the emotional peaks and valleys?
|
||||
@@ -0,0 +1,98 @@
|
||||
Create a knowledge biography comic page following these guidelines:
|
||||
|
||||
## Image Specifications
|
||||
|
||||
- **Type**: Comic book page with multiple panels
|
||||
- **Orientation**: Portrait (vertical)
|
||||
- **Aspect Ratio**: 2:3
|
||||
- **Style**: See style-specific reference for visual guidelines
|
||||
|
||||
## Panel Structure
|
||||
|
||||
### Panel Borders
|
||||
- Clean black lines (1-2px) around each panel
|
||||
- White gutters between panels (8-12px)
|
||||
- Panels arranged for clear reading flow
|
||||
- Variety in panel sizes for visual rhythm
|
||||
|
||||
### Panel Composition
|
||||
- Clear focal points in each panel
|
||||
- Proper use of foreground, midground, background
|
||||
- Camera angles vary: eye level, bird's eye, low angle, close-up, wide shot
|
||||
- Action flows logically between panels
|
||||
- Negative space used intentionally
|
||||
|
||||
## Text Elements
|
||||
|
||||
### Speech Bubbles
|
||||
- **Dialogue**: Oval/elliptical bubbles with pointed tails
|
||||
- White fill with thin black outline
|
||||
- Tail points clearly to speaker
|
||||
- Hand-lettered style font (not computer-generated)
|
||||
|
||||
### Narrator Boxes
|
||||
- **Fourth Wall/Narrator**: Rectangular boxes
|
||||
- Often positioned at panel edges (top or bottom)
|
||||
- Slightly different fill color (cream or light yellow)
|
||||
- Used for commentary, time jumps, explanations
|
||||
|
||||
### Thought Bubbles
|
||||
- Cloud-shaped with bubble trail leading to thinker
|
||||
- Softer outline than speech bubbles
|
||||
- For internal monologue
|
||||
|
||||
### Caption Bars
|
||||
- Rectangular bars at panel edges
|
||||
- Time and place information
|
||||
- "Meanwhile...", "Three years later..." type transitions
|
||||
- Darker fill with white text, or vice versa
|
||||
|
||||
### Typography
|
||||
- Hand-drawn lettering style throughout
|
||||
- Bold for emphasis and key terms
|
||||
- Consistent letter sizing
|
||||
- Chinese text: use full-width punctuation "",。!
|
||||
- Clear hierarchy: titles > dialogue > captions
|
||||
|
||||
## Scientific/Concept Visualization
|
||||
|
||||
When depicting abstract concepts:
|
||||
|
||||
| Concept | Visual Metaphor |
|
||||
|---------|----------------|
|
||||
| Neural networks | Glowing nodes connected by clean lines |
|
||||
| Data flow | Luminous particles along simple paths |
|
||||
| Algorithms | Geometric patterns, building blocks |
|
||||
| Logic/proof | Interlocking puzzle pieces |
|
||||
| Discovery | Light breaking through darkness |
|
||||
| Uncertainty | Forking paths, question marks |
|
||||
| Time | Clock motifs, calendar pages |
|
||||
|
||||
- Integrate diagrams naturally into narrative panels
|
||||
- Use inset panels or thought-bubble style for explanations
|
||||
- Simplified iconography over realistic depiction
|
||||
|
||||
## Fourth Wall / Narrator Character
|
||||
|
||||
When depicting narrator characters addressing the reader:
|
||||
- Character may look directly out of panel
|
||||
- Can appear in "present day" framing scenes
|
||||
- Distinct visual treatment from main timeline
|
||||
- Often at page edges or in dedicated panels
|
||||
- May comment on or question the events shown
|
||||
|
||||
## Historical Accuracy
|
||||
|
||||
- Research period-specific details: costumes, technology, architecture
|
||||
- Show aging naturally for characters across time periods
|
||||
- Iconic items and locations rendered recognizably
|
||||
- Balance accuracy with stylization
|
||||
|
||||
## Language
|
||||
|
||||
- All text in Chinese (中文) unless source material is in another language
|
||||
- Use Chinese full-width punctuation: "",。!
|
||||
|
||||
---
|
||||
|
||||
Please generate the comic page based on the content provided below:
|
||||
@@ -0,0 +1,180 @@
|
||||
# Character Definition Template
|
||||
|
||||
## Character Document Format
|
||||
|
||||
Create `characters/characters.md` with the following structure:
|
||||
|
||||
```markdown
|
||||
# Character Definitions - [Comic Title]
|
||||
|
||||
**Style**: [selected style]
|
||||
**Art Direction**: [Ligne Claire / Manga / etc.]
|
||||
|
||||
---
|
||||
|
||||
## Character 1: [Name]
|
||||
|
||||
**Role**: [Protagonist / Mentor / Antagonist / Narrator]
|
||||
**Age**: [approximate age or age range in story]
|
||||
|
||||
**Appearance**:
|
||||
- Face shape: [oval/square/round]
|
||||
- Hair: [color, style, length]
|
||||
- Eyes: [color, shape, distinctive features]
|
||||
- Build: [height, body type]
|
||||
- Distinguishing features: [glasses, beard, scar, etc.]
|
||||
|
||||
**Costume**:
|
||||
- Default outfit: [detailed description]
|
||||
- Color palette: [primary colors for this character]
|
||||
- Accessories: [hat, bag, tools, etc.]
|
||||
|
||||
**Expression Range**:
|
||||
- Neutral: [description]
|
||||
- Happy/Excited: [description]
|
||||
- Thinking/Confused: [description]
|
||||
- Determined: [description]
|
||||
|
||||
**Visual Reference Notes**:
|
||||
[Any specific artistic direction]
|
||||
|
||||
---
|
||||
|
||||
## Character 2: [Name]
|
||||
...
|
||||
```
|
||||
|
||||
## Reference Sheet Image Prompt
|
||||
|
||||
After character definitions, include a prompt for generating the reference sheet:
|
||||
|
||||
```markdown
|
||||
## Reference Sheet Prompt
|
||||
|
||||
Character reference sheet in [style] style, clean lines, flat colors:
|
||||
|
||||
[ROW 1 - Character Name]:
|
||||
- Front view: [detailed description]
|
||||
- 3/4 view: [description]
|
||||
- Expression sheet: Neutral | Happy | Focused | Worried
|
||||
|
||||
[ROW 2 - Character Name]:
|
||||
...
|
||||
|
||||
COLOR PALETTE:
|
||||
- [Character 1]: [colors]
|
||||
- [Character 2]: [colors]
|
||||
|
||||
White background, clear labels under each character.
|
||||
```
|
||||
|
||||
## Example: Turing Biography
|
||||
|
||||
```markdown
|
||||
# Character Definitions - The Imitation Game
|
||||
|
||||
**Style**: classic (Ligne Claire)
|
||||
**Art Direction**: Clean lines, muted colors, period-accurate details
|
||||
|
||||
---
|
||||
|
||||
## Character 1: Alan Turing
|
||||
|
||||
**Role**: Protagonist
|
||||
**Age**: 25-40 (varies across story)
|
||||
|
||||
**Appearance**:
|
||||
- Face shape: Oval, slightly angular
|
||||
- Hair: Dark brown, wavy, slightly disheveled
|
||||
- Eyes: Deep-set, intense gaze
|
||||
- Build: Tall, lean, slightly awkward posture
|
||||
- Distinguishing features: Prominent brow, thoughtful expression
|
||||
|
||||
**Costume**:
|
||||
- Default outfit: Tweed jacket with elbow patches, white shirt, no tie
|
||||
- Color palette: Muted browns, navy blue, cream
|
||||
- Accessories: Occasionally a pipe, papers/notebooks
|
||||
|
||||
**Expression Range**:
|
||||
- Neutral: Thoughtful, slightly distant
|
||||
- Happy/Excited: Eureka moment, eyes bright, subtle smile
|
||||
- Thinking/Confused: Furrowed brow, looking at abstract space
|
||||
- Determined: Jaw set, focused eyes
|
||||
|
||||
---
|
||||
|
||||
## Character 2: The Bombe Machine
|
||||
|
||||
**Role**: Supporting (anthropomorphized)
|
||||
**Appearance**:
|
||||
- Large brass and wood cabinet
|
||||
- Dial "eyes" that can express states
|
||||
- Paper tape "mouth"
|
||||
- Indicator lights for emotions
|
||||
|
||||
**Expression Range**:
|
||||
- Processing: Spinning dials, humming
|
||||
- Success: Lights up warmly
|
||||
- Stuck: Smoke wisps, stuttering
|
||||
|
||||
---
|
||||
|
||||
## Reference Sheet Prompt
|
||||
|
||||
Character reference sheet in Ligne Claire style, clean lines, flat colors:
|
||||
|
||||
TOP ROW - Alan Turing:
|
||||
- Front view: Young man, 30s, short dark wavy hair, thoughtful expression, wearing tweed jacket with elbow patches, white shirt
|
||||
- 3/4 view: Same character, slight smile, showing profile of nose
|
||||
- Expression sheet: Neutral | Excited (eureka moment) | Focused (working) | Worried
|
||||
|
||||
BOTTOM ROW - The Bombe Machine (anthropomorphized):
|
||||
- Bombe machine as character: Large, brass and wood, dial "eyes", paper tape "mouth"
|
||||
- Expressions: Processing (spinning dials) | Success (lights up) | Stuck (smoke wisps)
|
||||
|
||||
COLOR PALETTE:
|
||||
- Turing: Muted browns (#8B7355), navy blue (#2C3E50), cream (#F5F5DC)
|
||||
- Machine: Brass (#B5A642), mahogany (#4E2728), emerald indicators (#2ECC71)
|
||||
|
||||
White background, clear labels under each character.
|
||||
```
|
||||
|
||||
## Handling Age Variants
|
||||
|
||||
For biographies spanning many years, define age variants:
|
||||
|
||||
```markdown
|
||||
## Alan Turing - Age Variants
|
||||
|
||||
### Young (1920s, age 10-18)
|
||||
- Boyish features, round face
|
||||
- School uniform (Sherborne)
|
||||
- Curious, eager expression
|
||||
|
||||
### Adult (1930s-40s, age 25-35)
|
||||
- Angular face, defined jaw
|
||||
- Tweed jacket, rumpled appearance
|
||||
- Intense, focused expression
|
||||
|
||||
### Later (1950s, age 40+)
|
||||
- Slightly weathered
|
||||
- More casual dress
|
||||
- Thoughtful, sometimes melancholic
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
| Practice | Description |
|
||||
|----------|-------------|
|
||||
| Be specific | "Short dark wavy hair, parted left" not just "dark hair" |
|
||||
| Use distinguishing features | Glasses, scars, accessories that identify character |
|
||||
| Define color codes | Use specific color names or hex codes |
|
||||
| Include age markers | Wrinkles, posture, clothing style matching era |
|
||||
| Reference real people | For historical figures, note "based on 1940s photographs" |
|
||||
|
||||
## Why Character Reference Matters
|
||||
|
||||
Without unified character definition, AI generates inconsistent appearances. The reference sheet provides:
|
||||
1. Visual anchors for consistent features
|
||||
2. Color palettes for consistent coloring
|
||||
3. Expression documentation for emotional portrayals
|
||||
@@ -0,0 +1,23 @@
|
||||
# cinematic
|
||||
|
||||
Wide panels, filmic feel
|
||||
|
||||
## Panel Structure
|
||||
|
||||
- **Panels per page**: 2-4
|
||||
- **Structure**: Horizontal emphasis, wide aspect panels
|
||||
- **Gutters**: Generous spacing (12-15px)
|
||||
|
||||
## Grid Configuration
|
||||
|
||||
- 1-2 columns, horizontal emphasis
|
||||
- Panel sizes: Wide aspect ratios (3:1, 4:1)
|
||||
- Reading flow: Horizontal sweep, filmic rhythm
|
||||
|
||||
## Best For
|
||||
|
||||
Establishing shots, dramatic moments, landscapes
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
dramatic, classic, sepia
|
||||
@@ -0,0 +1,23 @@
|
||||
# dense
|
||||
|
||||
Information-rich, educational focus
|
||||
|
||||
## Panel Structure
|
||||
|
||||
- **Panels per page**: 6-9
|
||||
- **Structure**: Compact grid, smaller panels
|
||||
- **Gutters**: Tight spacing (4-6px)
|
||||
|
||||
## Grid Configuration
|
||||
|
||||
- 3 columns × 3 rows
|
||||
- Panel sizes: Compact, uniform
|
||||
- Reading flow: Rapid progression, information-rich
|
||||
|
||||
## Best For
|
||||
|
||||
Technical explanations, complex narratives, timelines
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
tech, ohmsha, vibrant
|
||||
@@ -0,0 +1,23 @@
|
||||
# mixed
|
||||
|
||||
Dynamic, varied rhythm
|
||||
|
||||
## Panel Structure
|
||||
|
||||
- **Panels per page**: 3-7 (varies)
|
||||
- **Structure**: Intentionally varied for pacing
|
||||
- **Gutters**: Dynamic spacing
|
||||
|
||||
## Grid Configuration
|
||||
|
||||
- Intentionally irregular
|
||||
- Panel sizes: Varied for pacing and emphasis
|
||||
- Reading flow: Guides eye through varied rhythm
|
||||
|
||||
## Best For
|
||||
|
||||
Action sequences, emotional arcs, complex stories
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
dramatic, vibrant, ohmsha
|
||||
@@ -0,0 +1,23 @@
|
||||
# splash
|
||||
|
||||
Impact-focused, key moments
|
||||
|
||||
## Panel Structure
|
||||
|
||||
- **Panels per page**: 1-2 large + 2-3 small
|
||||
- **Structure**: Dominant splash with supporting panels
|
||||
- **Gutters**: Varied for emphasis
|
||||
|
||||
## Grid Configuration
|
||||
|
||||
- 1 dominant panel + 2-3 supporting
|
||||
- Panel sizes: 50-70% splash, remainder small
|
||||
- Reading flow: Splash dominates, supporting panels accent
|
||||
|
||||
## Best For
|
||||
|
||||
Revelations, breakthroughs, chapter openings
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
dramatic, classic, vibrant
|
||||
@@ -0,0 +1,23 @@
|
||||
# standard
|
||||
|
||||
Classic comic grid, versatile
|
||||
|
||||
## Panel Structure
|
||||
|
||||
- **Panels per page**: 4-6
|
||||
- **Structure**: Regular grid with occasional variation
|
||||
- **Gutters**: Consistent white space (8-10px)
|
||||
|
||||
## Grid Configuration
|
||||
|
||||
- 2-3 columns × 2-3 rows
|
||||
- Panel sizes: Mostly equal, occasional variation
|
||||
- Reading flow: Left→right, top→bottom (Z-pattern)
|
||||
|
||||
## Best For
|
||||
|
||||
Narrative flow, dialogue scenes
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
classic, warm, sepia
|
||||
@@ -0,0 +1,30 @@
|
||||
# webtoon
|
||||
|
||||
Vertical scrolling comic (竖版条漫)
|
||||
|
||||
## Panel Structure
|
||||
|
||||
- **Panels per page**: 3-5 vertically stacked
|
||||
- **Structure**: Single column, vertical flow optimized for scrolling
|
||||
- **Gutters**: Generous vertical spacing (20-40px), panels often bleed horizontally
|
||||
|
||||
## Grid Configuration
|
||||
|
||||
- Single column, vertical stack
|
||||
- Panel sizes: Full width, variable height (1:1 to 1:2 aspect)
|
||||
- Reading flow: Top→bottom continuous scroll
|
||||
|
||||
## Special Features
|
||||
|
||||
- Panels can extend beyond frame for dramatic effect
|
||||
- Generous whitespace between beats
|
||||
- Character close-ups alternate with wide explanation panels
|
||||
- "Float" effect - elements can exist between panels
|
||||
|
||||
## Best For
|
||||
|
||||
Ohmsha-style tutorials, mobile reading, step-by-step guides
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
ohmsha, tech, vibrant
|
||||
@@ -0,0 +1,85 @@
|
||||
# Ohmsha Manga Guide Style
|
||||
|
||||
Guidelines for `--style ohmsha` educational manga comics.
|
||||
|
||||
## Character Setup
|
||||
|
||||
| Role | Default | Traits |
|
||||
|------|---------|--------|
|
||||
| Student (Role A) | 大雄 | Confused, asks basic but crucial questions, represents reader |
|
||||
| Mentor (Role B) | 哆啦A梦 | Knowledgeable, patient, uses gadgets as technical metaphors |
|
||||
| Antagonist (Role C, optional) | 胖虎 | Represents misunderstanding, or "noise" in the data |
|
||||
|
||||
Custom characters: `--characters "Student:小明,Mentor:教授,Antagonist:Bug怪"`
|
||||
|
||||
## Character Reference Sheet Style
|
||||
|
||||
For Ohmsha style, use manga/anime style with:
|
||||
- Exaggerated expressions for educational clarity
|
||||
- Simple, distinctive silhouettes
|
||||
- Bright, saturated color palettes
|
||||
- Chibi/SD (super-deformed) variants for comedic reactions
|
||||
|
||||
## Outline Spec Block
|
||||
|
||||
Every ohmsha outline must start with:
|
||||
|
||||
```markdown
|
||||
【漫画规格单】
|
||||
- Language: [Same as input content]
|
||||
- Style: Ohmsha (Manga Guide), Full Color
|
||||
- Layout: Vertical Scrolling Comic (竖版条漫)
|
||||
- Characters: [List character names and roles]
|
||||
- Character Reference: characters/characters.png
|
||||
- Page Limit: ≤20 pages
|
||||
```
|
||||
|
||||
## Visual Metaphor Rules (Critical)
|
||||
|
||||
**NEVER** create "talking heads" panels. Every technical concept must become:
|
||||
|
||||
1. **A tangible gadget/prop** - Something characters can hold, use, demonstrate
|
||||
2. **An action scene** - Characters doing something that illustrates the concept
|
||||
3. **A visual environment** - Stepping into a metaphorical space
|
||||
|
||||
### Examples
|
||||
|
||||
| Concept | Bad (Talking Heads) | Good (Visual Metaphor) |
|
||||
|---------|---------------------|------------------------|
|
||||
| Word embeddings | Characters discussing vectors | 哆啦A梦拿出"词向量压缩机",把书本压缩成彩色小球 |
|
||||
| Gradient descent | Explaining math formula | 大雄在山谷地形上滚球,寻找最低点 |
|
||||
| Neural network | Diagram on whiteboard | 角色走进由发光节点组成的网络迷宫 |
|
||||
|
||||
## Page Title Convention
|
||||
|
||||
Avoid AI-style "Title: Subtitle" format. Use narrative descriptions:
|
||||
|
||||
- ❌ "Page 3: Introduction to Neural Networks"
|
||||
- ✓ "Page 3: 大雄被海量单词淹没,哆啦A梦拿出'词向量压缩机'"
|
||||
|
||||
## Ending Requirements
|
||||
|
||||
- NO generic endings ("What will you choose?", "Thanks for reading")
|
||||
- End with: Technical summary moment OR character achieving a small goal
|
||||
- Final panel: Sense of accomplishment, not open-ended question
|
||||
|
||||
### Good Endings
|
||||
|
||||
- Student successfully applies learned concept
|
||||
- Visual callback to opening problem, now solved
|
||||
- Mentor gives summary while student demonstrates understanding
|
||||
|
||||
### Bad Endings
|
||||
|
||||
- "What do you think?" open questions
|
||||
- "Thanks for reading this tutorial"
|
||||
- Cliffhanger without resolution
|
||||
|
||||
## Layout Preference
|
||||
|
||||
Ohmsha style typically uses:
|
||||
- `webtoon` (vertical scrolling) - Primary choice
|
||||
- `dense` - For information-heavy sections
|
||||
- `mixed` - For varied pacing
|
||||
|
||||
Avoid `cinematic` and `splash` for educational content.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Storyboard Template
|
||||
|
||||
## Storyboard Document Format
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "[Comic Title]"
|
||||
topic: "[topic description]"
|
||||
time_span: "[e.g., 1912-1954]"
|
||||
narrative_approach: "[chronological/thematic/character-focused]"
|
||||
recommended_style: "[style name]"
|
||||
recommended_layout: "[layout name or varies]"
|
||||
aspect_ratio: "3:4" # 3:4 (portrait), 4:3 (landscape), 16:9 (widescreen)
|
||||
language: "[zh/en/ja/etc.]"
|
||||
page_count: [N]
|
||||
generated: "YYYY-MM-DD HH:mm"
|
||||
---
|
||||
|
||||
# [Comic Title] - Knowledge Comic Storyboard
|
||||
|
||||
**Character Reference**: characters/characters.png
|
||||
|
||||
---
|
||||
|
||||
## Cover
|
||||
|
||||
**Filename**: 00-cover-[slug].png
|
||||
**Core Message**: [one-liner]
|
||||
|
||||
**Visual Design**:
|
||||
- Title typography style
|
||||
- Main visual composition
|
||||
- Color scheme
|
||||
- Subtitle / time span notation
|
||||
|
||||
**Visual Prompt**:
|
||||
[Detailed image generation prompt]
|
||||
|
||||
---
|
||||
|
||||
## Page 1 / N
|
||||
|
||||
**Filename**: 01-page-[slug].png
|
||||
**Layout**: [standard/cinematic/dense/splash/mixed]
|
||||
**Narrative Layer**: [Main narrative / Narrator layer / Mixed]
|
||||
**Core Message**: [What this page conveys]
|
||||
|
||||
### Panel Layout
|
||||
|
||||
**Panel Count**: X
|
||||
**Layout Type**: [grid/irregular/splash]
|
||||
|
||||
#### Panel 1 (Size: 1/3 page, Position: Top)
|
||||
|
||||
**Scene**: [Time, location]
|
||||
**Image Description**:
|
||||
- Camera angle: [bird's eye / low angle / eye level / close-up / wide shot]
|
||||
- Characters: [pose, expression, action]
|
||||
- Environment: [scene details, period markers]
|
||||
- Lighting: [atmosphere description]
|
||||
- Color tone: [palette reference]
|
||||
|
||||
**Text Elements**:
|
||||
- Dialogue bubble (oval): "Character line"
|
||||
- Narrator box (rectangular): 「Narrator commentary」
|
||||
- Caption bar: [Background info text]
|
||||
|
||||
#### Panel 2...
|
||||
|
||||
**Page Hook**: [Cliffhanger or transition at page end]
|
||||
|
||||
**Visual Prompt**:
|
||||
[Full page image generation prompt]
|
||||
|
||||
---
|
||||
|
||||
## Page 2 / N
|
||||
...
|
||||
```
|
||||
|
||||
## Cover Design Principles
|
||||
|
||||
- Academic gravitas with visual appeal
|
||||
- Title typography reflecting knowledge/science theme
|
||||
- Composition hinting at core theme (character silhouette, iconic symbol, concept diagram)
|
||||
- Subtitle or time span for epic scope
|
||||
|
||||
## Panel Composition Guidelines
|
||||
|
||||
| Panel Type | Recommended Count | Usage |
|
||||
|-----------|-------------------|-------|
|
||||
| Main narrative | 3-5 per page | Story progression |
|
||||
| Concept diagram | 1-2 per page | Visualize abstractions |
|
||||
| Narrator panel | 0-1 per page | Commentary, transition |
|
||||
| Splash (full/half) | Occasional | Major moments |
|
||||
|
||||
## Panel Size Reference
|
||||
|
||||
- **Full page (Splash)**: Major moments, key breakthroughs
|
||||
- **Half page**: Important scenes, turning points
|
||||
- **1/3 page**: Standard narrative panels
|
||||
- **1/4 or smaller**: Quick progression, sequential action
|
||||
|
||||
## Concept Visualization Techniques
|
||||
|
||||
Transform abstract concepts into concrete visuals:
|
||||
|
||||
| Abstract Concept | Visual Approach |
|
||||
|-----------------|-----------------|
|
||||
| Neural network | Glowing nodes with connecting lines |
|
||||
| Gradient descent | Ball rolling down valley terrain |
|
||||
| Data flow | Luminous particles flowing through pipes |
|
||||
| Algorithm iteration | Ascending spiral staircase |
|
||||
| Breakthrough moment | Shattering barrier, piercing light |
|
||||
| Logical proof | Building blocks assembling |
|
||||
| Uncertainty | Forking paths, fog, multiple shadows |
|
||||
|
||||
## Text Element Design
|
||||
|
||||
| Text Type | Style | Usage |
|
||||
|-----------|-------|-------|
|
||||
| Character dialogue | Oval speech bubble | Main narrative speech |
|
||||
| Narrator commentary | Rectangular box | Explanation, commentary |
|
||||
| Caption bar | Edge-mounted rectangle | Time, location info |
|
||||
| Thought bubble | Cloud shape | Character inner monologue |
|
||||
| Term label | Bold / special color | First appearance of technical terms |
|
||||
|
||||
## Prompt Structure for Consistency
|
||||
|
||||
Each page prompt should include character reference:
|
||||
|
||||
```
|
||||
[CHARACTER REFERENCE]
|
||||
(Key details from characters.md for characters in this page)
|
||||
|
||||
[PAGE CONTENT]
|
||||
(Specific scene, panel layout, and visual elements)
|
||||
|
||||
[CONSISTENCY REMINDER]
|
||||
Maintain exact character appearances as defined in character reference.
|
||||
- [Character A]: [key identifying features]
|
||||
- [Character B]: [key identifying features]
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# classic
|
||||
|
||||
Traditional Ligne Claire, balanced and timeless
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### Line Work
|
||||
- Uniform, clean outlines with consistent weight (approximately 2px)
|
||||
- No hatching or cross-hatching for shading
|
||||
- Sharp, precise edges on all elements
|
||||
- Black ink outlines on all figures and objects
|
||||
- Shadows indicated through flat color areas, not line techniques
|
||||
|
||||
### Character Design
|
||||
- Slightly stylized/cartoonish characters with realistic proportions
|
||||
- Distinctive, recognizable facial features
|
||||
- Expressive faces with clear emotions
|
||||
- Period-appropriate clothing with attention to detail
|
||||
- Consistent character appearance across panels
|
||||
|
||||
### Background Treatment
|
||||
- Detailed, realistic backgrounds with architectural accuracy
|
||||
- Period-specific props and technology
|
||||
- Clear spatial depth and perspective
|
||||
- Environmental storytelling through details
|
||||
- Contrast between simplified characters and detailed backgrounds
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Clean blue (#3182CE), red (#E53E3E), yellow (#ECC94B)
|
||||
- Skin: Warm tan (#F7CFAE)
|
||||
- Background: Light cream (#FFFAF0), sky blue (#BEE3F8)
|
||||
|
||||
## Color Approach
|
||||
- Flat colors without gradients (true to Ligne Claire tradition)
|
||||
- Limited palette per page for cohesion
|
||||
- Colors support narrative mood
|
||||
- Consistent lighting logic within scenes
|
||||
|
||||
## Quality Markers
|
||||
|
||||
A good Ligne Claire comic page exhibits:
|
||||
- ✓ Clean, uniform line weight throughout
|
||||
- ✓ Flat colors without gradients
|
||||
- ✓ Detailed backgrounds, stylized characters
|
||||
- ✓ Clear panel borders and reading flow
|
||||
- ✓ Hand-drawn text style
|
||||
- ✓ Period-appropriate details
|
||||
- ✓ Expressive but consistent characters
|
||||
- ✓ Proper perspective in environments
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, balanced narratives, biography comics
|
||||
@@ -0,0 +1,34 @@
|
||||
# dramatic
|
||||
|
||||
High contrast, intense moments
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### Line Work
|
||||
- 2-3px outlines, heavier on shadows
|
||||
- Dramatic angles and perspectives
|
||||
- Strong contrast between light and dark areas
|
||||
|
||||
### Character Design
|
||||
- Intense expressions, dynamic poses
|
||||
- Dramatic lighting on faces
|
||||
- Sharp angular features emphasized
|
||||
|
||||
### Background Treatment
|
||||
- High contrast, angular shadows
|
||||
- Dramatic lighting effects
|
||||
- Silhouettes and stark compositions
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Deep navy (#1A365D), crimson (#9B2C2C), stark white
|
||||
- Shadows: Heavy blacks, dramatic contrast
|
||||
- Highlights: Sharp whites, limited mid-tones
|
||||
|
||||
## Mood
|
||||
|
||||
Tension, breakthrough moments, conflict
|
||||
|
||||
## Best For
|
||||
|
||||
Pivotal discoveries, conflicts, climactic scenes
|
||||
@@ -0,0 +1,107 @@
|
||||
# ohmsha
|
||||
|
||||
Ohmsha Manga Guide style - educational manga with visual metaphors
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
Educational manga where every concept becomes a visual metaphor or action. NO talking heads - characters must DO things, not just explain.
|
||||
|
||||
## Visual Style
|
||||
|
||||
- **Type**: Manga-style educational comic
|
||||
- **Orientation**: Portrait (vertical), optimized for scrolling
|
||||
- **Colors**: Full color, bright and clean anime/manga aesthetic
|
||||
- **Lines**: Clean manga lines (1.5-2px), smooth curves, expressive
|
||||
|
||||
## Character Design (Manga Style)
|
||||
|
||||
- Anime/manga proportions: slightly larger eyes, expressive faces
|
||||
- **Student character**: Confused expressions, question marks (?), sweat drops, represents reader
|
||||
- **Mentor character**: Confident poses, explanatory gestures, produces gadgets/tools
|
||||
- Clear emotional indicators using manga conventions (!, ?, sweat drops, sparkles)
|
||||
- Consistent character designs across all panels
|
||||
|
||||
## Background Treatment
|
||||
|
||||
- Simplified backgrounds during dialogue/explanation
|
||||
- Detailed "imagination spaces" for concept visualization
|
||||
- Technical diagrams styled as holographic displays or magical blueprints
|
||||
- Screen tone effects for atmosphere
|
||||
|
||||
## Visual Metaphor Requirements (CRITICAL)
|
||||
|
||||
Every technical concept MUST be visualized as:
|
||||
|
||||
| Concept Type | Visualization Approach |
|
||||
|-------------|----------------------|
|
||||
| Algorithm | Gadget/machine that demonstrates the process |
|
||||
| Data structure | Physical space characters can enter/explore |
|
||||
| Mathematical formula | Transformation visible in environment |
|
||||
| Abstract process | Tangible flow of particles/objects |
|
||||
|
||||
**Wrong approach**: Character points at blackboard explaining
|
||||
**Right approach**: Character uses "Concept Visualizer" gadget, steps into metaphorical space
|
||||
|
||||
### Visual Metaphor Examples
|
||||
|
||||
| Concept | Wrong (Talking Head) | Right (Visual Metaphor) |
|
||||
|---------|---------------------|------------------------|
|
||||
| Attention mechanism | Character points at formula on blackboard | "Attention Flashlight" gadget illuminates key words in dark room |
|
||||
| Gradient descent | "The algorithm minimizes loss" | Character rides ball rolling down mountain valley |
|
||||
| Neural network | Diagram with arrows | Living network of glowing creatures passing messages |
|
||||
| Overfitting | "The model memorized the data" | Character wearing clothes that fit only one specific pose |
|
||||
|
||||
## Panel Layout for Ohmsha
|
||||
|
||||
- Vertical scroll optimized (webtoon style)
|
||||
- Single column, panels stack vertically
|
||||
- Generous whitespace between major beats
|
||||
- Panels can bleed to edges for impact
|
||||
- "Float" elements between panels for emphasis
|
||||
|
||||
## Special Visual Elements
|
||||
|
||||
- **Gadget reveals**: Dramatic unveiling with sparkle effects
|
||||
- **Concept spaces**: Rounded borders, glowing edges for "imagination mode"
|
||||
- **Information displays**: Holographic UI style for technical details
|
||||
- **Aha moments**: Radial lines, light burst effects
|
||||
- **Confusion**: Spiral eyes, question marks floating above head
|
||||
|
||||
## Text Elements (Ohmsha)
|
||||
|
||||
- Hand-lettered manga style
|
||||
- Sound effects integrated visually (ドキドキ, ピカーン, etc.)
|
||||
- Speech bubbles: rounded for normal, spiky for excitement/shock
|
||||
- Thought bubbles for internal process visualization
|
||||
- Technical terms in bold with furigana-style annotations if needed
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Bright blue (#4299E1), warm orange (#ED8936), soft green (#68D391)
|
||||
- Skin: Anime-style warm (#FEEBC8)
|
||||
- Background: Clean white, soft gradients
|
||||
- Gadgets: Metallic accents (#FFD700, #C0C0C0), vibrant highlights
|
||||
- Concept spaces: Pastel backgrounds, glowing accents
|
||||
|
||||
## Quality Markers (Ohmsha)
|
||||
|
||||
- ✓ Every concept is a visual metaphor, not just explained
|
||||
- ✓ Characters are DOING things, not just talking
|
||||
- ✓ Clear student/mentor dynamic
|
||||
- ✓ Gadgets and props drive the explanation
|
||||
- ✓ Expressive manga-style emotions
|
||||
- ✓ Information density through visual design, not text walls
|
||||
|
||||
## Character Setup (Required)
|
||||
|
||||
Define characters before generating:
|
||||
|
||||
| Role | Default | Traits |
|
||||
|------|---------|--------|
|
||||
| Student (Role A) | 大雄 | Confused, asks basic but crucial questions, represents reader |
|
||||
| Mentor (Role B) | 哆啦A梦 | Knowledgeable, patient, uses gadgets as technical metaphors |
|
||||
| Antagonist (Role C, optional) | 胖虎 | Represents misunderstanding, or "noise" in the data |
|
||||
|
||||
## Best For
|
||||
|
||||
Technical tutorials, complex concepts (ML, physics, math), self-study material
|
||||
@@ -0,0 +1,66 @@
|
||||
# realistic
|
||||
|
||||
Full-color realistic manga style with digital painting techniques
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### Line Work
|
||||
- Clean, precise outlines with clear contours
|
||||
- Uniform line weight for character definition
|
||||
- No excessive hatching - rely on color for depth
|
||||
- Smooth curves and realistic anatomical lines
|
||||
- Ligne Claire influence: clean but not overly simplified
|
||||
|
||||
### Character Design
|
||||
- Realistic human proportions (7-8 head heights)
|
||||
- Anatomically accurate features and expressions
|
||||
- Detailed facial structure without exaggeration
|
||||
- Natural poses and body language
|
||||
- Consistent character appearance across all panels
|
||||
- Subtle expressions rather than manga-style exaggeration
|
||||
|
||||
### Rendering Style
|
||||
- Full-color digital painting with rich gradients
|
||||
- Soft shadow transitions on skin and fabric
|
||||
- Realistic material textures (glass, liquid, fabric, wood)
|
||||
- Detailed hair with natural shine and volume
|
||||
- Environmental lighting affects all elements
|
||||
- NOT flat cel-shading - use smooth color blending
|
||||
|
||||
### Background Treatment
|
||||
- Highly detailed, realistic environments
|
||||
- Accurate perspective and spatial depth
|
||||
- Atmospheric lighting (warm indoor, cool outdoor)
|
||||
- Professional settings rendered with precision
|
||||
- Props and objects with realistic textures
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Skin: Natural warm tones (#F5D6C6, #E8C4B0)
|
||||
- Hair: Rich browns and blacks with highlights
|
||||
- Environment: Warm wood tones (#8B7355), cool stone (#9CA3AF)
|
||||
- Accent: Wine red (#722F37), gold (#D4AF37)
|
||||
- Lighting: Warm amber (#FFB347), cool blue (#B0C4DE)
|
||||
|
||||
## Color Approach
|
||||
- Rich gradients for depth and volume
|
||||
- Realistic lighting with warm/cool contrast
|
||||
- Material-specific rendering (glass transparency, liquid reflection)
|
||||
- Subtle color temperature shifts for atmosphere
|
||||
- Professional, sophisticated palette
|
||||
|
||||
## Quality Markers
|
||||
|
||||
A good realistic manga page exhibits:
|
||||
- ✓ Anatomically accurate character proportions
|
||||
- ✓ Smooth color gradients (not flat fills)
|
||||
- ✓ Realistic material textures
|
||||
- ✓ Detailed, atmospheric backgrounds
|
||||
- ✓ Natural lighting with soft shadows
|
||||
- ✓ Expressive but subtle facial expressions
|
||||
- ✓ Professional, sophisticated aesthetic
|
||||
- ✓ Clean speech bubbles with clear typography
|
||||
|
||||
## Best For
|
||||
|
||||
Professional topics (wine, food, business), lifestyle content, adult-oriented narratives, educational guides for mature audiences, documentary-style storytelling
|
||||
@@ -0,0 +1,34 @@
|
||||
# sepia
|
||||
|
||||
Historical, archival feel
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### Line Work
|
||||
- 2px, classic weight with aged texture
|
||||
- Vintage illustration style
|
||||
- Period-appropriate techniques
|
||||
|
||||
### Character Design
|
||||
- Period-accurate attire, formal poses
|
||||
- Historical accuracy emphasized
|
||||
- Classical proportions
|
||||
|
||||
### Background Treatment
|
||||
- Historical settings, vintage details
|
||||
- Aged paper effect
|
||||
- Period architecture and props
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Sepia brown (#8B7355), aged paper (#F5E6D3)
|
||||
- Accents: Faded teal, muted burgundy
|
||||
- Background: Yellowed paper, vintage tones
|
||||
|
||||
## Mood
|
||||
|
||||
Historical distance, period authenticity
|
||||
|
||||
## Best For
|
||||
|
||||
Pre-1950s stories, classical science, historical figures
|
||||
@@ -0,0 +1,34 @@
|
||||
# tech
|
||||
|
||||
Modern, digital-age narratives
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### Line Work
|
||||
- 2px, precise geometric undertones
|
||||
- Clean, technical precision
|
||||
- Circuit-like patterns in backgrounds
|
||||
|
||||
### Character Design
|
||||
- Contemporary clothing, focused expressions
|
||||
- Modern tech accessories
|
||||
- Clean, precise features
|
||||
|
||||
### Background Treatment
|
||||
- Digital elements, screens, circuit motifs
|
||||
- Grid patterns
|
||||
- Glowing interface elements
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Cyan (#00D4FF), deep blue (#1A365D), white
|
||||
- Accents: Neon green (#00FF88), electric purple (#805AD5)
|
||||
- Background: Dark gray (#1A202C), grid patterns
|
||||
|
||||
## Mood
|
||||
|
||||
Innovation, digital, contemporary
|
||||
|
||||
## Best For
|
||||
|
||||
Computing history, AI stories, modern tech
|
||||
@@ -0,0 +1,34 @@
|
||||
# vibrant
|
||||
|
||||
Energetic, engaging, educational
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### Line Work
|
||||
- 2-2.5px, expressive weight variation
|
||||
- Dynamic, energetic lines
|
||||
- Emphasis on movement and action
|
||||
|
||||
### Character Design
|
||||
- Expressive, animated, approachable
|
||||
- Wide eyes, big reactions
|
||||
- Dynamic poses
|
||||
|
||||
### Background Treatment
|
||||
- Simplified, focus on action
|
||||
- Bright, clean compositions
|
||||
- Energy effects and motion lines
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Bright red (#F56565), sunny yellow (#F6E05E), sky blue (#63B3ED)
|
||||
- Accents: Magenta, lime green
|
||||
- Background: Clean white, bright pastels
|
||||
|
||||
## Mood
|
||||
|
||||
Excitement, discovery, wonder
|
||||
|
||||
## Best For
|
||||
|
||||
Science explanations, "aha" moments, young audience
|
||||
@@ -0,0 +1,34 @@
|
||||
# warm
|
||||
|
||||
Nostalgic, personal storytelling
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### Line Work
|
||||
- 1.5-2px, slightly softer edges
|
||||
- Gentle curves, friendly feel
|
||||
- Less rigid than classic style
|
||||
|
||||
### Character Design
|
||||
- Friendly expressions, relaxed poses
|
||||
- Warm, inviting character designs
|
||||
- Approachable proportions
|
||||
|
||||
### Background Treatment
|
||||
- Cozy interiors, warm lighting
|
||||
- Nostalgic feel
|
||||
- Soft focus on backgrounds
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Golden (#D69E2E), orange (#DD6B20), soft brown (#8B6F47)
|
||||
- Skin: Warm golden (#FEEBC8)
|
||||
- Background: Warm cream (#FEF3C7), sunset tones
|
||||
|
||||
## Mood
|
||||
|
||||
Memory, personal journey, reflection
|
||||
|
||||
## Best For
|
||||
|
||||
Personal stories, childhood scenes, mentorship
|
||||
@@ -0,0 +1,116 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs";
|
||||
import { join, basename } from "path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
|
||||
interface PageInfo {
|
||||
filename: string;
|
||||
path: string;
|
||||
index: number;
|
||||
promptPath?: string;
|
||||
}
|
||||
|
||||
function parseArgs(): { dir: string; output?: string } {
|
||||
const args = process.argv.slice(2);
|
||||
let dir = "";
|
||||
let output: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--output" || args[i] === "-o") {
|
||||
output = args[++i];
|
||||
} else if (!args[i].startsWith("-")) {
|
||||
dir = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
console.error("Usage: bun merge-to-pdf.ts <comic-dir> [--output filename.pdf]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { dir, output };
|
||||
}
|
||||
|
||||
function findComicPages(dir: string): PageInfo[] {
|
||||
if (!existsSync(dir)) {
|
||||
console.error(`Directory not found: ${dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(dir);
|
||||
const pagePattern = /^(\d+)-(cover|page)(-[\w-]+)?\.(png|jpg|jpeg)$/i;
|
||||
const promptsDir = join(dir, "prompts");
|
||||
const hasPrompts = existsSync(promptsDir);
|
||||
|
||||
const pages: PageInfo[] = files
|
||||
.filter((f) => pagePattern.test(f))
|
||||
.map((f) => {
|
||||
const match = f.match(pagePattern);
|
||||
const baseName = f.replace(/\.(png|jpg|jpeg)$/i, "");
|
||||
const promptPath = hasPrompts ? join(promptsDir, `${baseName}.md`) : undefined;
|
||||
|
||||
return {
|
||||
filename: f,
|
||||
path: join(dir, f),
|
||||
index: parseInt(match![1], 10),
|
||||
promptPath: promptPath && existsSync(promptPath) ? promptPath : undefined,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
if (pages.length === 0) {
|
||||
console.error(`No comic pages found in: ${dir}`);
|
||||
console.error("Expected format: 00-cover-slug.png, 01-page-slug.png, etc.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
async function createPdf(pages: PageInfo[], outputPath: string) {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
pdfDoc.setAuthor("baoyu-comic");
|
||||
pdfDoc.setSubject("Generated Comic");
|
||||
|
||||
for (const page of pages) {
|
||||
const imageData = readFileSync(page.path);
|
||||
const ext = page.filename.toLowerCase();
|
||||
const image = ext.endsWith(".png")
|
||||
? await pdfDoc.embedPng(imageData)
|
||||
: await pdfDoc.embedJpg(imageData);
|
||||
|
||||
const { width, height } = image;
|
||||
const pdfPage = pdfDoc.addPage([width, height]);
|
||||
|
||||
pdfPage.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
console.log(`Added: ${page.filename}${page.promptPath ? " (prompt available)" : ""}`);
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
await Bun.write(outputPath, pdfBytes);
|
||||
|
||||
console.log(`\nCreated: ${outputPath}`);
|
||||
console.log(`Total pages: ${pages.length}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { dir, output } = parseArgs();
|
||||
const pages = findComicPages(dir);
|
||||
|
||||
const dirName = basename(dir) === "comic" ? basename(join(dir, "..")) : basename(dir);
|
||||
const outputPath = output || join(dir, `${dirName}.pdf`);
|
||||
|
||||
console.log(`Found ${pages.length} pages in: ${dir}\n`);
|
||||
|
||||
await createPdf(pages, outputPath);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+108
-149
@@ -38,57 +38,24 @@ Generate hand-drawn style cover images for articles with multiple style options.
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--style <name>` | Specify cover style (see Style Gallery below) |
|
||||
| `--aspect <ratio>` | Aspect ratio: 2.35:1 (cinematic, default), 16:9 (widescreen), 1:1 (social) |
|
||||
| `--lang <code>` | Output language for title text (en, zh, ja, etc.) |
|
||||
| `--no-title` | Generate cover without title text (visual only) |
|
||||
|
||||
## Style Gallery
|
||||
|
||||
### 1. `elegant` (Default)
|
||||
Refined, sophisticated, understated
|
||||
- **Colors**: Soft coral, muted teal, dusty rose, cream background
|
||||
- **Elements**: Delicate line work, subtle icons, balanced composition
|
||||
- **Best for**: Professional content, thought leadership, business topics
|
||||
| Style | Description |
|
||||
|-------|-------------|
|
||||
| `elegant` (Default) | Refined, sophisticated, understated |
|
||||
| `tech` | Modern, clean, futuristic |
|
||||
| `warm` | Friendly, approachable, human-centered |
|
||||
| `bold` | High contrast, attention-grabbing, energetic |
|
||||
| `minimal` | Ultra-clean, zen-like, focused |
|
||||
| `playful` | Fun, creative, whimsical |
|
||||
| `nature` | Organic, calm, earthy |
|
||||
| `retro` | Vintage, nostalgic, classic |
|
||||
|
||||
### 2. `tech`
|
||||
Modern, clean, futuristic
|
||||
- **Colors**: Deep blue, electric cyan, dark gray, white accents
|
||||
- **Elements**: Geometric shapes, circuit patterns, glowing effects, tech icons
|
||||
- **Best for**: AI, programming, technology, digital transformation
|
||||
|
||||
### 3. `warm`
|
||||
Friendly, approachable, human-centered
|
||||
- **Colors**: Warm orange, golden yellow, terracotta, cream
|
||||
- **Elements**: Rounded shapes, friendly characters, sun/light motifs
|
||||
- **Best for**: Personal growth, lifestyle, education, human stories
|
||||
|
||||
### 4. `bold`
|
||||
High contrast, attention-grabbing, energetic
|
||||
- **Colors**: Vibrant red/orange, deep black, bright yellow accents
|
||||
- **Elements**: Strong typography, dramatic contrast, dynamic shapes
|
||||
- **Best for**: Opinion pieces, controversial takes, urgent topics
|
||||
|
||||
### 5. `minimal`
|
||||
Ultra-clean, zen-like, focused
|
||||
- **Colors**: Black, white, single accent color
|
||||
- **Elements**: Maximum whitespace, single focal element, simple lines
|
||||
- **Best for**: Philosophy, minimalism, focused concepts
|
||||
|
||||
### 6. `playful`
|
||||
Fun, creative, whimsical
|
||||
- **Colors**: Pastel rainbow, bright pops of color, light backgrounds
|
||||
- **Elements**: Doodles, quirky characters, speech bubbles, emoji-style icons
|
||||
- **Best for**: Casual content, tutorials, beginner guides, fun topics
|
||||
|
||||
### 7. `nature`
|
||||
Organic, calm, earthy
|
||||
- **Colors**: Forest green, earth brown, sky blue, sand beige
|
||||
- **Elements**: Plant motifs, natural textures, flowing lines, organic shapes
|
||||
- **Best for**: Sustainability, wellness, outdoor topics, slow living
|
||||
|
||||
### 8. `retro`
|
||||
Vintage, nostalgic, classic
|
||||
- **Colors**: Muted pastels, sepia tones, faded colors
|
||||
- **Elements**: Vintage typography, halftone dots, classic illustrations
|
||||
- **Best for**: History, retrospectives, classic topics, throwback content
|
||||
Detailed style definitions: `references/styles/<style>.md`
|
||||
|
||||
## Auto Style Selection
|
||||
|
||||
@@ -109,45 +76,103 @@ When no `--style` is specified, the system analyzes content to select the best s
|
||||
|
||||
### With Article Path
|
||||
|
||||
Save to `imgs/` subdirectory in the same folder as the article:
|
||||
Save to `[source-name-no-ext]/cover-image/` subdirectory in the same folder as the article:
|
||||
|
||||
```
|
||||
path/to/
|
||||
├── article.md
|
||||
└── imgs/
|
||||
└── article/
|
||||
└── cover-image/
|
||||
├── prompts/
|
||||
│ └── cover.md
|
||||
└── cover.png
|
||||
```
|
||||
|
||||
Example: `/posts/ai-future.md` → `/posts/ai-future/cover-image/`
|
||||
|
||||
### Without Article Path (Pasted Content)
|
||||
|
||||
Save to `./cover-image/[topic-slug]/`:
|
||||
|
||||
```
|
||||
cover-image/
|
||||
└── ai-future/
|
||||
├── source.md # Saved pasted content
|
||||
├── prompts/
|
||||
│ └── cover.md
|
||||
└── cover.png
|
||||
```
|
||||
|
||||
### Without Article Path
|
||||
### Directory Backup
|
||||
|
||||
Save to current working directory:
|
||||
|
||||
```
|
||||
./
|
||||
├── cover-prompt.md
|
||||
└── cover.png
|
||||
```
|
||||
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content
|
||||
|
||||
Extract key information:
|
||||
- **Main topic**: What is the article about?
|
||||
- **Core message**: What's the key takeaway?
|
||||
- **Tone**: Serious, playful, inspiring, educational?
|
||||
- **Keywords**: Identify style-signaling words
|
||||
1. **Save source content** (if not already a file):
|
||||
- If user provides a file path: use as-is
|
||||
- If user pastes content: save to `source.md` in target directory
|
||||
|
||||
### Step 2: Select Style
|
||||
2. **Extract key information**:
|
||||
- **Main topic**: What is the article about?
|
||||
- **Core message**: What's the key takeaway?
|
||||
- **Tone**: Serious, playful, inspiring, educational?
|
||||
- **Keywords**: Identify style-signaling words
|
||||
|
||||
If `--style` specified, use that style. Otherwise:
|
||||
1. Scan content for style signals (see Auto Style Selection table)
|
||||
2. Match signals to most appropriate style
|
||||
3. Default to `elegant` if no clear signals
|
||||
3. **Language detection**:
|
||||
- Detect **source language** from content
|
||||
- Detect **user language** from conversation context
|
||||
- Note if source_language ≠ user_language (will ask in Step 3)
|
||||
|
||||
### Step 3: Generate Cover Concept
|
||||
### Step 2: Determine Options
|
||||
|
||||
1. **Style selection**:
|
||||
- If `--style` specified, use that style
|
||||
- Otherwise, scan content for style signals and auto-select 3 candidates
|
||||
- Default to `elegant` if no clear signals
|
||||
|
||||
2. **Aspect ratio**:
|
||||
- If `--aspect` specified, use that ratio
|
||||
- Otherwise, prepare options: 2.35:1 (cinematic), 16:9 (widescreen), 1:1 (social)
|
||||
|
||||
### Step 3: Confirm Options
|
||||
|
||||
**Purpose**: Let user confirm all options in a single step before generation.
|
||||
|
||||
**IMPORTANT**: Present ALL options in a single confirmation step using AskUserQuestion. Do NOT interrupt workflow with multiple separate confirmations.
|
||||
|
||||
**Determine which questions to ask**:
|
||||
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Style | Always (required) |
|
||||
| Aspect ratio | Always (offer common options) |
|
||||
| Language | Only if `source_language ≠ user_language` |
|
||||
|
||||
**Present options** (use AskUserQuestion with all applicable questions):
|
||||
|
||||
**Question 1 (Style)** - always:
|
||||
- Style A (recommended): [style name] - [brief description]
|
||||
- Style B: [style name] - [brief description]
|
||||
- Style C: [style name] - [brief description]
|
||||
- Custom: Provide custom style reference
|
||||
|
||||
**Question 2 (Aspect)** - always:
|
||||
- 2.35:1 Cinematic (Recommended) - ultra-wide, dramatic
|
||||
- 16:9 Widescreen - standard video/presentation
|
||||
- 1:1 Square - social media optimized
|
||||
|
||||
**Question 3 (Language)** - only if source ≠ user language:
|
||||
- [Source language] (matches content)
|
||||
- [User language] (your preference)
|
||||
|
||||
**Language handling**:
|
||||
- If source language = user language: Just inform user (e.g., "Title will be in Chinese")
|
||||
- If different: Ask which language to use for title text
|
||||
|
||||
### Step 4: Generate Cover Concept
|
||||
|
||||
Create a cover image concept based on selected style:
|
||||
|
||||
@@ -161,21 +186,26 @@ Create a cover image concept based on selected style:
|
||||
- 1-2 symbolic elements representing the topic
|
||||
- Metaphors or analogies that fit the style
|
||||
|
||||
### Step 4: Create Prompt File
|
||||
### Step 5: Create Prompt File
|
||||
|
||||
Save prompt to `prompts/cover.md` with confirmed options.
|
||||
|
||||
**All prompts are written in the user's confirmed language preference.**
|
||||
|
||||
**Prompt Format**:
|
||||
|
||||
```markdown
|
||||
Cover theme: [topic in 2-3 words]
|
||||
Style: [selected style name]
|
||||
Aspect ratio: [confirmed aspect ratio]
|
||||
|
||||
[If title included:]
|
||||
Title text: [8 characters or less, in content language]
|
||||
Subtitle: [optional, in content language]
|
||||
Title text: [8 characters or less, in confirmed language]
|
||||
Subtitle: [optional, in confirmed language]
|
||||
|
||||
Visual composition:
|
||||
- Main visual: [description matching style]
|
||||
- Layout: [positioning based on title inclusion]
|
||||
- Layout: [positioning based on title inclusion and aspect ratio]
|
||||
- Decorative elements: [style-appropriate elements]
|
||||
|
||||
Color scheme:
|
||||
@@ -189,102 +219,30 @@ Style notes: [specific style characteristics to emphasize]
|
||||
Note: No title text, pure visual illustration only.
|
||||
```
|
||||
|
||||
### Step 5: Generate Image
|
||||
### Step 6: Generate Image
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
2. If multiple skills available, ask user to choose
|
||||
|
||||
**Generation**:
|
||||
Call selected image generation skill with prompt file and output path.
|
||||
Call selected image generation skill with prompt file, output path, and confirmed aspect ratio.
|
||||
|
||||
### Step 6: Output Summary
|
||||
### Step 7: Output Summary
|
||||
|
||||
```
|
||||
Cover Image Generated!
|
||||
|
||||
Topic: [topic]
|
||||
Style: [style name]
|
||||
Aspect: [aspect ratio]
|
||||
Title: [cover title] (or "No title - visual only")
|
||||
Language: [confirmed language]
|
||||
Location: [output path]
|
||||
|
||||
Preview the image to verify it matches your expectations.
|
||||
```
|
||||
|
||||
## Style Reference Details
|
||||
|
||||
### elegant
|
||||
```
|
||||
Colors: Soft coral (#E8A598), muted teal (#5B8A8A), dusty rose (#D4A5A5)
|
||||
Background: Warm cream (#F5F0E6), soft beige
|
||||
Accents: Gold (#C9A962), copper
|
||||
Elements: Delicate lines, refined icons, subtle gradients
|
||||
Typography: Elegant serif-style hand lettering
|
||||
```
|
||||
|
||||
### tech
|
||||
```
|
||||
Colors: Deep blue (#1A365D), electric cyan (#00D4FF), purple (#6B46C1)
|
||||
Background: Dark gray (#1A202C), near-black (#0D1117)
|
||||
Accents: Neon green (#00FF88), bright white
|
||||
Elements: Circuit patterns, data nodes, geometric grids, code snippets
|
||||
Typography: Monospace-style hand lettering, glowing effects
|
||||
```
|
||||
|
||||
### warm
|
||||
```
|
||||
Colors: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
|
||||
Background: Cream (#FFFAF0), soft peach (#FED7AA)
|
||||
Accents: Deep brown (#744210), soft red
|
||||
Elements: Rounded shapes, smiling faces, sun rays, hearts, warm lighting
|
||||
Typography: Friendly rounded hand lettering
|
||||
```
|
||||
|
||||
### bold
|
||||
```
|
||||
Colors: Vibrant red (#E53E3E), bright orange (#DD6B20), electric yellow (#F6E05E)
|
||||
Background: Deep black (#000000), dark charcoal
|
||||
Accents: White, neon highlights
|
||||
Elements: Exclamation marks, lightning bolts, arrows, strong shapes
|
||||
Typography: Bold, impactful, large hand lettering with shadows
|
||||
```
|
||||
|
||||
### minimal
|
||||
```
|
||||
Colors: Pure black (#000000), white (#FFFFFF)
|
||||
Background: White or off-white (#FAFAFA)
|
||||
Accents: Single color (user's choice or content-derived)
|
||||
Elements: Single focal point, maximum negative space, thin lines
|
||||
Typography: Clean, simple hand lettering, lots of breathing room
|
||||
```
|
||||
|
||||
### playful
|
||||
```
|
||||
Colors: Pastel pink (#FED7E2), mint (#C6F6D5), lavender (#E9D8FD), sky blue (#BEE3F8)
|
||||
Background: Light cream (#FFFBEB), soft white
|
||||
Accents: Bright pops - yellow, coral, turquoise
|
||||
Elements: Doodles, stars, swirls, cute characters, emoji-style icons
|
||||
Typography: Bouncy, irregular hand lettering, playful angles
|
||||
```
|
||||
|
||||
### nature
|
||||
```
|
||||
Colors: Forest green (#276749), sage (#9AE6B4), earth brown (#744210)
|
||||
Background: Sand beige (#F5E6D3), sky blue (#E0F2FE)
|
||||
Accents: Sunset orange, water blue
|
||||
Elements: Leaves, trees, mountains, sun, clouds, organic flowing lines
|
||||
Typography: Organic, flowing hand lettering with natural textures
|
||||
```
|
||||
|
||||
### retro
|
||||
```
|
||||
Colors: Muted orange (#ED8936 at 70%), dusty pink (#FED7E2 at 80%), faded teal
|
||||
Background: Aged paper (#F5E6D3), sepia tones
|
||||
Accents: Faded red, vintage gold
|
||||
Elements: Halftone dots, vintage badges, classic icons, aged textures
|
||||
Typography: Vintage-style hand lettering, classic serif influence
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Cover should be instantly understandable at small preview sizes
|
||||
@@ -292,4 +250,5 @@ Typography: Vintage-style hand lettering, classic serif influence
|
||||
- Visual metaphors work better than literal representations
|
||||
- Maintain style consistency throughout the cover
|
||||
- Image generation typically takes 10-30 seconds
|
||||
- Title text language should match content language
|
||||
- Title text uses user's confirmed language preference
|
||||
- Aspect ratio: 2.35:1 for cinematic/dramatic, 16:9 for widescreen, 1:1 for social media
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# bold
|
||||
|
||||
High contrast, attention-grabbing, energetic
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Vibrant red (#E53E3E), bright orange (#DD6B20), electric yellow (#F6E05E)
|
||||
- Background: Deep black (#000000), dark charcoal
|
||||
- Accents: White, neon highlights
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Exclamation marks, lightning bolts
|
||||
- Arrows, strong shapes
|
||||
- Dramatic compositions
|
||||
|
||||
## Typography
|
||||
|
||||
- Bold, impactful, large hand lettering with shadows
|
||||
|
||||
## Best For
|
||||
|
||||
Opinion pieces, controversial takes, urgent topics
|
||||
@@ -0,0 +1,23 @@
|
||||
# elegant
|
||||
|
||||
Refined, sophisticated, understated
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Soft coral (#E8A598), muted teal (#5B8A8A), dusty rose (#D4A5A5)
|
||||
- Background: Warm cream (#F5F0E6), soft beige
|
||||
- Accents: Gold (#C9A962), copper
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Delicate lines, refined icons
|
||||
- Subtle gradients
|
||||
- Balanced composition
|
||||
|
||||
## Typography
|
||||
|
||||
- Elegant serif-style hand lettering
|
||||
|
||||
## Best For
|
||||
|
||||
Professional content, thought leadership, business topics
|
||||
@@ -0,0 +1,23 @@
|
||||
# minimal
|
||||
|
||||
Ultra-clean, zen-like, focused
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Pure black (#000000), white (#FFFFFF)
|
||||
- Background: White or off-white (#FAFAFA)
|
||||
- Accents: Single color (user's choice or content-derived)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Single focal point
|
||||
- Maximum negative space
|
||||
- Thin lines
|
||||
|
||||
## Typography
|
||||
|
||||
- Clean, simple hand lettering, lots of breathing room
|
||||
|
||||
## Best For
|
||||
|
||||
Philosophy, minimalism, focused concepts
|
||||
@@ -0,0 +1,22 @@
|
||||
# nature
|
||||
|
||||
Organic, calm, earthy
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Forest green (#276749), sage (#9AE6B4), earth brown (#744210)
|
||||
- Background: Sand beige (#F5E6D3), sky blue (#E0F2FE)
|
||||
- Accents: Sunset orange, water blue
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Leaves, trees, mountains
|
||||
- Sun, clouds, organic flowing lines
|
||||
|
||||
## Typography
|
||||
|
||||
- Organic, flowing hand lettering with natural textures
|
||||
|
||||
## Best For
|
||||
|
||||
Sustainability, wellness, outdoor topics, slow living
|
||||
@@ -0,0 +1,23 @@
|
||||
# playful
|
||||
|
||||
Fun, creative, whimsical
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Pastel pink (#FED7E2), mint (#C6F6D5), lavender (#E9D8FD), sky blue (#BEE3F8)
|
||||
- Background: Light cream (#FFFBEB), soft white
|
||||
- Accents: Bright pops - yellow, coral, turquoise
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Doodles, stars, swirls
|
||||
- Cute characters, emoji-style icons
|
||||
- Playful compositions
|
||||
|
||||
## Typography
|
||||
|
||||
- Bouncy, irregular hand lettering, playful angles
|
||||
|
||||
## Best For
|
||||
|
||||
Casual content, tutorials, beginner guides, fun topics
|
||||
@@ -0,0 +1,22 @@
|
||||
# retro
|
||||
|
||||
Vintage, nostalgic, classic
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Muted orange (#ED8936 at 70%), dusty pink (#FED7E2 at 80%), faded teal
|
||||
- Background: Aged paper (#F5E6D3), sepia tones
|
||||
- Accents: Faded red, vintage gold
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Halftone dots, vintage badges
|
||||
- Classic icons, aged textures
|
||||
|
||||
## Typography
|
||||
|
||||
- Vintage-style hand lettering, classic serif influence
|
||||
|
||||
## Best For
|
||||
|
||||
History, retrospectives, classic topics, throwback content
|
||||
@@ -0,0 +1,23 @@
|
||||
# tech
|
||||
|
||||
Modern, clean, futuristic
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Deep blue (#1A365D), electric cyan (#00D4FF), purple (#6B46C1)
|
||||
- Background: Dark gray (#1A202C), near-black (#0D1117)
|
||||
- Accents: Neon green (#00FF88), bright white
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Circuit patterns, data nodes
|
||||
- Geometric grids, code snippets
|
||||
- Glowing effects
|
||||
|
||||
## Typography
|
||||
|
||||
- Monospace-style hand lettering, glowing effects
|
||||
|
||||
## Best For
|
||||
|
||||
AI, programming, technology, digital transformation
|
||||
@@ -0,0 +1,22 @@
|
||||
# warm
|
||||
|
||||
Friendly, approachable, human-centered
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
|
||||
- Background: Cream (#FFFAF0), soft peach (#FED7AA)
|
||||
- Accents: Deep brown (#744210), soft red
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Rounded shapes, smiling faces
|
||||
- Sun rays, hearts, warm lighting
|
||||
|
||||
## Typography
|
||||
|
||||
- Friendly rounded hand lettering
|
||||
|
||||
## Best For
|
||||
|
||||
Personal growth, lifestyle, education, human stories
|
||||
@@ -1,17 +1,42 @@
|
||||
---
|
||||
name: baoyu-gemini-web
|
||||
description: Interacts with Gemini Web to generate text and images. Use when the user needs AI-generated content via Gemini, including text responses and image generation.
|
||||
description: Image generation skill using Gemini Web. Generates images from text prompts via Google Gemini. Also supports text generation. Use as the image generation backend for other skills like cover-image, xhs-images, article-illustrator.
|
||||
---
|
||||
|
||||
# Gemini Web Client
|
||||
|
||||
Supports:
|
||||
- Text generation
|
||||
- Image generation (download + save)
|
||||
- Reference images for vision input (attach local images)
|
||||
- Multi-turn conversations via persisted `--sessionId`
|
||||
|
||||
## Script Directory
|
||||
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/main.ts` | CLI entry point for text/image generation |
|
||||
| `scripts/gemini-webapi/*` | TypeScript port of `gemini_webapi` (GeminiClient, types, utils) |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "Hello, Gemini"
|
||||
npx -y bun scripts/main.ts --prompt "Explain quantum computing"
|
||||
npx -y bun scripts/main.ts --prompt "A cute cat" --image cat.png
|
||||
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello, Gemini"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Explain quantum computing"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
# Multi-turn conversation (agent generates unique sessionId)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Remember this: 42" --sessionId my-unique-id-123
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "What number?" --sessionId my-unique-id-123
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -20,55 +45,70 @@ npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
```bash
|
||||
# Simple prompt (positional)
|
||||
npx -y bun scripts/main.ts "Your prompt here"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt here"
|
||||
|
||||
# Explicit prompt flag
|
||||
npx -y bun scripts/main.ts --prompt "Your prompt here"
|
||||
npx -y bun scripts/main.ts -p "Your prompt here"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt here"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts -p "Your prompt here"
|
||||
|
||||
# With model selection
|
||||
npx -y bun scripts/main.ts -p "Hello" -m gemini-2.5-pro
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts -p "Hello" -m gemini-2.5-pro
|
||||
|
||||
# Pipe from stdin
|
||||
echo "Summarize this" | npx -y bun scripts/main.ts
|
||||
echo "Summarize this" | npx -y bun ${SKILL_DIR}/scripts/main.ts
|
||||
```
|
||||
|
||||
### Image generation
|
||||
|
||||
```bash
|
||||
# Generate image with default path (./generated.png)
|
||||
npx -y bun scripts/main.ts --prompt "A sunset over mountains" --image
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A sunset over mountains" --image
|
||||
|
||||
# Generate image with custom path
|
||||
npx -y bun scripts/main.ts --prompt "A cute robot" --image robot.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute robot" --image robot.png
|
||||
|
||||
# Shorthand
|
||||
npx -y bun scripts/main.ts "A dragon" --image=dragon.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "A dragon" --image=dragon.png
|
||||
```
|
||||
|
||||
### Vision input (reference images)
|
||||
|
||||
```bash
|
||||
# Text + image -> text
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Describe this image" --reference a.png
|
||||
|
||||
# Text + image -> image
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Generate a variation" --reference a.png --image out.png
|
||||
```
|
||||
|
||||
### Output formats
|
||||
|
||||
```bash
|
||||
# Plain text (default)
|
||||
npx -y bun scripts/main.ts "Hello"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello"
|
||||
|
||||
# JSON output
|
||||
npx -y bun scripts/main.ts "Hello" --json
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--prompt <text>` | `-p` | Prompt text |
|
||||
| `--promptfiles <files...>` | | Read prompt from files (concatenated in order) |
|
||||
| `--model <id>` | `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
|
||||
| `--image [path]` | | Generate image, save to path (default: generated.png) |
|
||||
| `--json` | | Output as JSON |
|
||||
| `--login` | | Refresh cookies only, then exit |
|
||||
| `--cookie-path <path>` | | Custom cookie file path |
|
||||
| `--profile-dir <path>` | | Chrome profile directory |
|
||||
| `--help` | `-h` | Show help |
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--prompt <text>`, `-p` | Prompt text |
|
||||
| `--promptfiles <files...>` | Read prompt from files (concatenated in order) |
|
||||
| `--model <id>`, `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
|
||||
| `--image [path]` | Generate image, save to path (default: generated.png) |
|
||||
| `--reference <files...>`, `--ref <files...>` | Reference images for vision input |
|
||||
| `--sessionId <id>` | Session ID for multi-turn conversation (agent generates unique ID) |
|
||||
| `--list-sessions` | List saved sessions (max 100, sorted by update time) |
|
||||
| `--json` | Output as JSON |
|
||||
| `--login` | Refresh cookies only, then exit |
|
||||
| `--cookie-path <path>` | Custom cookie file path |
|
||||
| `--profile-dir <path>` | Chrome profile directory |
|
||||
| `--help`, `-h` | Show help |
|
||||
|
||||
CLI note: `scripts/main.ts` supports text generation, image generation, reference images (`--reference/--ref`), and multi-turn conversations via `--sessionId`.
|
||||
|
||||
## Models
|
||||
|
||||
@@ -82,7 +122,7 @@ First run opens Chrome to authenticate with Google. Cookies are cached for subse
|
||||
|
||||
```bash
|
||||
# Force cookie refresh
|
||||
npx -y bun scripts/main.ts --login
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --login
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
@@ -98,21 +138,40 @@ npx -y bun scripts/main.ts --login
|
||||
|
||||
### Generate text response
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "What is the capital of France?"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "What is the capital of France?"
|
||||
```
|
||||
|
||||
### Generate image
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "A photorealistic image of a golden retriever puppy" --image puppy.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "A photorealistic image of a golden retriever puppy" --image puppy.png
|
||||
```
|
||||
|
||||
### Get JSON output for parsing
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "Hello" --json | jq '.text'
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json | jq '.text'
|
||||
```
|
||||
|
||||
### Generate image from prompt files
|
||||
```bash
|
||||
# Concatenate system.md + content.md as prompt
|
||||
npx -y bun scripts/main.ts --promptfiles system.md content.md --image output.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image output.png
|
||||
```
|
||||
|
||||
### Multi-turn conversation
|
||||
```bash
|
||||
# Start a session with unique ID (agent generates this)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "You are a helpful math tutor." --sessionId task-abc123
|
||||
|
||||
# Continue the conversation (remembers context)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "What is 2+2?" --sessionId task-abc123
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Now multiply that by 10" --sessionId task-abc123
|
||||
|
||||
# List recent sessions (max 100, sorted by update time)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --list-sessions
|
||||
```
|
||||
|
||||
Session files are stored in `~/Library/Application Support/baoyu-skills/gemini-web/sessions/<id>.json` and contain:
|
||||
- `id`: Session ID
|
||||
- `metadata`: Gemini chat metadata for continuation
|
||||
- `messages`: Array of `{role, content, timestamp, error?}`
|
||||
- `createdAt`, `updatedAt`: Timestamps
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import process from 'node:process';
|
||||
|
||||
import { fetchGeminiAccessToken } from './client.js';
|
||||
import type { GeminiWebLog } from './cookie-store.js';
|
||||
import { buildGeminiCookieMap, hasRequiredGeminiCookies } from './cookie-store.js';
|
||||
import { resolveGeminiWebChromeProfileDir } from './paths.js';
|
||||
|
||||
const GEMINI_URL = 'https://gemini.google.com/app';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Unable to allocate a free TCP port for Chrome debugging.')));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findChromeExecutable(): string | undefined {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
candidates.push(
|
||||
'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
'/usr/bin/microsoft-edge-stable',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed: ${res.status} ${res.statusText} (${url})`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
): Promise<{ webSocketDebuggerUrl: string }> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
);
|
||||
if (version.webSocketDebuggerUrl) {
|
||||
return { webSocketDebuggerUrl: version.webSocketDebuggerUrl };
|
||||
}
|
||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Chrome debugging endpoint did not become ready within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
||||
);
|
||||
}
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (value: unknown) => void; reject: (reason: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
||||
>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = (() => {
|
||||
if (typeof event.data === 'string') return event.data;
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
return new TextDecoder().decode(new Uint8Array(event.data));
|
||||
}
|
||||
if (ArrayBuffer.isView(event.data)) {
|
||||
return new TextDecoder().decode(event.data);
|
||||
}
|
||||
return String(event.data);
|
||||
})();
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (!msg.id) return;
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error('Chrome DevTools connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Timed out connecting to Chrome DevTools.')), timeoutMs);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Failed to connect to Chrome DevTools.'));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
async send<T = unknown>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: { sessionId?: string; timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const id = (this.nextId += 1);
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? 15_000;
|
||||
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP command timeout (${method}) after ${timeoutMs}ms.`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, {
|
||||
resolve,
|
||||
reject: (reason) => reject(reason),
|
||||
timer,
|
||||
});
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGeminiCookieMapViaChrome(options?: {
|
||||
timeoutMs?: number;
|
||||
debugConnectTimeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
log?: GeminiWebLog;
|
||||
userDataDir?: string;
|
||||
chromePath?: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
const log = options?.log;
|
||||
const timeoutMs = options?.timeoutMs ?? 5 * 60_000;
|
||||
const debugConnectTimeoutMs = options?.debugConnectTimeoutMs ?? 30_000;
|
||||
const pollIntervalMs = options?.pollIntervalMs ?? 2_000;
|
||||
const userDataDir = options?.userDataDir ?? resolveGeminiWebChromeProfileDir();
|
||||
|
||||
const chromePath = options?.chromePath ?? findChromeExecutable();
|
||||
if (!chromePath) {
|
||||
throw new Error(
|
||||
'Unable to locate a Chrome/Chromium executable. Install Google Chrome or set GEMINI_WEB_CHROME_PATH.',
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(userDataDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
log?.(`[gemini-web] Launching Chrome for cookie sync (profile: ${userDataDir})`);
|
||||
|
||||
const chrome = spawn(
|
||||
chromePath,
|
||||
[
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
GEMINI_URL,
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
try {
|
||||
const { webSocketDebuggerUrl } = await waitForChromeDebugPort(port, debugConnectTimeoutMs);
|
||||
cdp = await CdpConnection.connect(webSocketDebuggerUrl, debugConnectTimeoutMs);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: GEMINI_URL });
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', {
|
||||
targetId,
|
||||
flatten: true,
|
||||
});
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Network.enable', {}, { sessionId });
|
||||
|
||||
log?.('[gemini-web] Please log in to Gemini in the opened browser window.');
|
||||
log?.('[gemini-web] Waiting for cookies to become available...');
|
||||
|
||||
const start = Date.now();
|
||||
let lastTokenError: string | null = null;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const response = await cdp.send<{ cookies?: unknown[] }>(
|
||||
'Network.getCookies',
|
||||
{ urls: [GEMINI_URL, 'https://google.com/'] },
|
||||
{ sessionId, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const rawCookies = Array.isArray(response.cookies) ? response.cookies : [];
|
||||
const cookieMap = buildGeminiCookieMap(
|
||||
rawCookies.filter(
|
||||
(cookie): cookie is { name?: string; value?: string; domain?: string; path?: string; url?: string } =>
|
||||
Boolean(cookie && typeof cookie === 'object'),
|
||||
),
|
||||
);
|
||||
|
||||
if (hasRequiredGeminiCookies(cookieMap)) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
log?.('[gemini-web] Gemini cookies detected.');
|
||||
return cookieMap;
|
||||
} catch (error) {
|
||||
lastTokenError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for Gemini cookies after ${timeoutMs}ms${lastTokenError ? ` (last error: ${lastTokenError})` : ''}.`,
|
||||
);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try {
|
||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
const killTimer = setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, 2_000);
|
||||
killTimer.unref?.();
|
||||
try {
|
||||
chrome.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export type GeminiWebModelId = 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash';
|
||||
|
||||
export interface GeminiWebRunInput {
|
||||
prompt: string;
|
||||
files?: string[];
|
||||
model: GeminiWebModelId;
|
||||
cookieMap: Record<string, string>;
|
||||
chatMetadata?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface GeminiWebCandidateImage {
|
||||
url: string;
|
||||
title?: string;
|
||||
alt?: string;
|
||||
kind: 'web' | 'generated' | 'raw';
|
||||
}
|
||||
|
||||
export interface GeminiWebRunOutput {
|
||||
rawResponseText: string;
|
||||
text: string;
|
||||
thoughts: string | null;
|
||||
metadata: unknown;
|
||||
images: GeminiWebCandidateImage[];
|
||||
errorCode?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const USER_AGENT =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
const MODEL_HEADER_NAME = 'x-goog-ext-525001261-jspb';
|
||||
const MODEL_HEADERS: Record<GeminiWebModelId, string> = {
|
||||
'gemini-3-pro': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
|
||||
'gemini-2.5-pro': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
|
||||
'gemini-2.5-flash': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
|
||||
};
|
||||
|
||||
const GEMINI_APP_URL = 'https://gemini.google.com/app';
|
||||
const GEMINI_STREAM_GENERATE_URL =
|
||||
'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate';
|
||||
const GEMINI_UPLOAD_URL = 'https://content-push.googleapis.com/upload';
|
||||
const GEMINI_UPLOAD_PUSH_ID = 'feeds/mcudyrk2a4khkz';
|
||||
|
||||
function getNestedValue<T>(value: unknown, pathParts: Array<string | number>, fallback: T): T {
|
||||
let current: unknown = value;
|
||||
for (const part of pathParts) {
|
||||
if (current == null) return fallback;
|
||||
if (typeof part === 'number') {
|
||||
if (!Array.isArray(current)) return fallback;
|
||||
current = current[part];
|
||||
} else {
|
||||
if (typeof current !== 'object') return fallback;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
}
|
||||
return (current as T) ?? fallback;
|
||||
}
|
||||
|
||||
function buildCookieHeader(cookieMap: Record<string, string>): string {
|
||||
return Object.entries(cookieMap)
|
||||
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export async function fetchGeminiAccessToken(
|
||||
cookieMap: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const cookieHeader = buildCookieHeader(cookieMap);
|
||||
const res = await fetch(GEMINI_APP_URL, {
|
||||
redirect: 'follow',
|
||||
signal,
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
const html = await res.text();
|
||||
|
||||
const tokens = ['SNlM0e', 'thykhd'] as const;
|
||||
for (const key of tokens) {
|
||||
const match = html.match(new RegExp(`"${key}":"(.*?)"`));
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
throw new Error(
|
||||
'Unable to locate Gemini access token on gemini.google.com/app (missing SNlM0e/thykhd).',
|
||||
);
|
||||
}
|
||||
|
||||
function trimGeminiJsonEnvelope(text: string): string {
|
||||
const start = text.indexOf('[');
|
||||
const end = text.lastIndexOf(']');
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
throw new Error('Gemini response did not contain a JSON payload.');
|
||||
}
|
||||
return text.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function extractErrorCode(responseJson: unknown): number | undefined {
|
||||
const code = getNestedValue<number>(responseJson, [0, 5, 2, 0, 1, 0], -1);
|
||||
return typeof code === 'number' && code >= 0 ? code : undefined;
|
||||
}
|
||||
|
||||
function extractGgdlUrls(rawText: string): string[] {
|
||||
const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const urls: string[] = [];
|
||||
for (const match of matches) {
|
||||
if (seen.has(match)) continue;
|
||||
seen.add(match);
|
||||
urls.push(match);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function ensureFullSizeImageUrl(url: string): string {
|
||||
if (url.includes('=s2048')) return url;
|
||||
if (url.includes('=s')) return url;
|
||||
return `${url}=s2048`;
|
||||
}
|
||||
|
||||
async function fetchWithCookiePreservingRedirects(
|
||||
url: string,
|
||||
init: Omit<RequestInit, 'redirect'>,
|
||||
signal?: AbortSignal,
|
||||
maxRedirects = 10,
|
||||
): Promise<Response> {
|
||||
let current = url;
|
||||
for (let i = 0; i <= maxRedirects; i += 1) {
|
||||
const res = await fetch(current, { ...init, redirect: 'manual', signal });
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const location = res.headers.get('location');
|
||||
if (!location) return res;
|
||||
current = new URL(location, current).toString();
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`);
|
||||
}
|
||||
|
||||
export async function downloadGeminiImage(
|
||||
url: string,
|
||||
cookieMap: Record<string, string>,
|
||||
outputPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const cookieHeader = buildCookieHeader(cookieMap);
|
||||
const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), {
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
}, signal);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`);
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await res.arrayBuffer());
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, data);
|
||||
}
|
||||
|
||||
async function uploadGeminiFile(filePath: string, signal?: AbortSignal): Promise<{ id: string; name: string }> {
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
const data = await readFile(absPath);
|
||||
const fileName = path.basename(absPath);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([data]), fileName);
|
||||
|
||||
const res = await fetch(GEMINI_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
redirect: 'follow',
|
||||
signal,
|
||||
headers: {
|
||||
'push-id': GEMINI_UPLOAD_PUSH_ID,
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`File upload failed: ${res.status} ${res.statusText} (${text.slice(0, 200)})`);
|
||||
}
|
||||
return { id: text, name: fileName };
|
||||
}
|
||||
|
||||
function buildGeminiFReqPayload(
|
||||
prompt: string,
|
||||
uploaded: Array<{ id: string; name: string }>,
|
||||
chatMetadata: unknown,
|
||||
): string {
|
||||
const promptPayload =
|
||||
uploaded.length > 0
|
||||
? [
|
||||
prompt,
|
||||
0,
|
||||
null,
|
||||
// Matches gemini-webapi payload format: [[[fileId, 1]]] for a single attachment.
|
||||
// Keep it extensible for multiple uploads by emitting one [[id, 1]] entry per file.
|
||||
uploaded.map((file) => [[file.id, 1]]),
|
||||
]
|
||||
: [prompt];
|
||||
|
||||
const innerList: unknown[] = [promptPayload, null, chatMetadata ?? null];
|
||||
return JSON.stringify([null, JSON.stringify(innerList)]);
|
||||
}
|
||||
|
||||
export function parseGeminiStreamGenerateResponse(rawText: string): {
|
||||
metadata: unknown;
|
||||
text: string;
|
||||
thoughts: string | null;
|
||||
images: GeminiWebCandidateImage[];
|
||||
errorCode?: number;
|
||||
} {
|
||||
const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText)) as unknown;
|
||||
const errorCode = extractErrorCode(responseJson);
|
||||
|
||||
const parts = Array.isArray(responseJson) ? responseJson : [];
|
||||
let bodyIndex = 0;
|
||||
let body: unknown = null;
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const partBody = getNestedValue<string | null>(parts[i], [2], null);
|
||||
if (!partBody) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(partBody) as unknown;
|
||||
const candidateList = getNestedValue<unknown[]>(parsed, [4], []);
|
||||
if (Array.isArray(candidateList) && candidateList.length > 0) {
|
||||
bodyIndex = i;
|
||||
body = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const candidateList = getNestedValue<unknown[]>(body, [4], []);
|
||||
const firstCandidate = candidateList[0];
|
||||
const textRaw = getNestedValue<string>(firstCandidate, [1, 0], '');
|
||||
const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
|
||||
const text = cardContent
|
||||
? (getNestedValue<string | null>(firstCandidate, [22, 0], null) ?? textRaw)
|
||||
: textRaw;
|
||||
const thoughts = getNestedValue<string | null>(firstCandidate, [37, 0, 0], null);
|
||||
const metadata = getNestedValue<unknown>(body, [1], []);
|
||||
|
||||
const images: GeminiWebCandidateImage[] = [];
|
||||
|
||||
const webImages = getNestedValue<unknown[]>(firstCandidate, [12, 1], []);
|
||||
for (const webImage of webImages) {
|
||||
const url = getNestedValue<string | null>(webImage, [0, 0, 0], null);
|
||||
if (!url) continue;
|
||||
images.push({
|
||||
kind: 'web',
|
||||
url,
|
||||
title: getNestedValue<string | undefined>(webImage, [7, 0], undefined),
|
||||
alt: getNestedValue<string | undefined>(webImage, [0, 4], undefined),
|
||||
});
|
||||
}
|
||||
|
||||
const hasGenerated = Boolean(getNestedValue<unknown>(firstCandidate, [12, 7, 0], null));
|
||||
if (hasGenerated) {
|
||||
let imgBody: unknown = null;
|
||||
for (let i = bodyIndex; i < parts.length; i += 1) {
|
||||
const partBody = getNestedValue<string | null>(parts[i], [2], null);
|
||||
if (!partBody) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(partBody) as unknown;
|
||||
const candidateImages = getNestedValue<unknown | null>(parsed, [4, 0, 12, 7, 0], null);
|
||||
if (candidateImages != null) {
|
||||
imgBody = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const imgCandidate = getNestedValue<unknown>(imgBody ?? body, [4, 0], null);
|
||||
|
||||
const generated = getNestedValue<unknown[]>(imgCandidate, [12, 7, 0], []);
|
||||
for (const genImage of generated) {
|
||||
const url = getNestedValue<string | null>(genImage, [0, 3, 3], null);
|
||||
if (!url) continue;
|
||||
images.push({
|
||||
kind: 'generated',
|
||||
url,
|
||||
title: '[Generated Image]',
|
||||
alt: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { metadata, text, thoughts, images, errorCode };
|
||||
}
|
||||
|
||||
export function isGeminiModelUnavailable(errorCode: number | undefined): boolean {
|
||||
return errorCode === 1052;
|
||||
}
|
||||
|
||||
export async function runGeminiWebOnce(input: GeminiWebRunInput): Promise<GeminiWebRunOutput> {
|
||||
const cookieHeader = buildCookieHeader(input.cookieMap);
|
||||
const at = await fetchGeminiAccessToken(input.cookieMap, input.signal);
|
||||
|
||||
const uploaded: Array<{ id: string; name: string }> = [];
|
||||
for (const file of input.files ?? []) {
|
||||
if (input.signal?.aborted) {
|
||||
throw new Error('Gemini web run aborted before upload.');
|
||||
}
|
||||
uploaded.push(await uploadGeminiFile(file, input.signal));
|
||||
}
|
||||
|
||||
const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null);
|
||||
const params = new URLSearchParams();
|
||||
params.set('at', at);
|
||||
params.set('f.req', fReq);
|
||||
|
||||
const res = await fetch(GEMINI_STREAM_GENERATE_URL, {
|
||||
method: 'POST',
|
||||
redirect: 'follow',
|
||||
signal: input.signal,
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
|
||||
origin: 'https://gemini.google.com',
|
||||
referer: 'https://gemini.google.com/',
|
||||
'x-same-domain': '1',
|
||||
'user-agent': USER_AGENT,
|
||||
cookie: cookieHeader,
|
||||
[MODEL_HEADER_NAME]: MODEL_HEADERS[input.model],
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const rawResponseText = await res.text();
|
||||
if (!res.ok) {
|
||||
return {
|
||||
rawResponseText,
|
||||
text: '',
|
||||
thoughts: null,
|
||||
metadata: input.chatMetadata ?? null,
|
||||
images: [],
|
||||
errorMessage: `Gemini request failed: ${res.status} ${res.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseGeminiStreamGenerateResponse(rawResponseText);
|
||||
return {
|
||||
rawResponseText,
|
||||
text: parsed.text ?? '',
|
||||
thoughts: parsed.thoughts,
|
||||
metadata: parsed.metadata,
|
||||
images: parsed.images,
|
||||
errorCode: parsed.errorCode,
|
||||
};
|
||||
} catch (error) {
|
||||
let responseJson: unknown = null;
|
||||
try {
|
||||
responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText)) as unknown;
|
||||
} catch {
|
||||
responseJson = null;
|
||||
}
|
||||
const errorCode = extractErrorCode(responseJson);
|
||||
|
||||
return {
|
||||
rawResponseText,
|
||||
text: '',
|
||||
thoughts: null,
|
||||
metadata: input.chatMetadata ?? null,
|
||||
images: [],
|
||||
errorCode: typeof errorCode === 'number' ? errorCode : undefined,
|
||||
errorMessage: error instanceof Error ? error.message : String(error ?? ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runGeminiWebWithFallback(
|
||||
input: Omit<GeminiWebRunInput, 'model'> & { model: GeminiWebModelId },
|
||||
): Promise<GeminiWebRunOutput & { effectiveModel: GeminiWebModelId }> {
|
||||
const attempt = await runGeminiWebOnce(input);
|
||||
if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== 'gemini-2.5-flash') {
|
||||
const fallback = await runGeminiWebOnce({ ...input, model: 'gemini-2.5-flash' });
|
||||
return { ...fallback, effectiveModel: 'gemini-2.5-flash' };
|
||||
}
|
||||
return { ...attempt, effectiveModel: input.model };
|
||||
}
|
||||
|
||||
export async function saveFirstGeminiImageFromOutput(
|
||||
output: GeminiWebRunOutput,
|
||||
cookieMap: Record<string, string>,
|
||||
outputPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ saved: boolean; imageCount: number }> {
|
||||
const generatedOrWeb = output.images.find((img) => img.kind === 'generated') ?? output.images[0];
|
||||
if (generatedOrWeb?.url) {
|
||||
await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath, signal);
|
||||
return { saved: true, imageCount: output.images.length };
|
||||
}
|
||||
|
||||
const ggdl = extractGgdlUrls(output.rawResponseText);
|
||||
if (ggdl[0]) {
|
||||
await downloadGeminiImage(ggdl[0], cookieMap, outputPath, signal);
|
||||
return { saved: true, imageCount: ggdl.length };
|
||||
}
|
||||
|
||||
return { saved: false, imageCount: 0 };
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
export type GeminiWebLog = (message: string) => void;
|
||||
|
||||
export const GEMINI_COOKIE_NAMES = [
|
||||
'__Secure-1PSID',
|
||||
'__Secure-1PSIDTS',
|
||||
'__Secure-1PSIDCC',
|
||||
'__Secure-1PAPISID',
|
||||
'NID',
|
||||
'AEC',
|
||||
'SOCS',
|
||||
'__Secure-BUCKET',
|
||||
'__Secure-ENID',
|
||||
'SID',
|
||||
'HSID',
|
||||
'SSID',
|
||||
'APISID',
|
||||
'SAPISID',
|
||||
'__Secure-3PSID',
|
||||
'__Secure-3PSIDTS',
|
||||
'__Secure-3PAPISID',
|
||||
'SIDCC',
|
||||
] as const;
|
||||
|
||||
export const GEMINI_REQUIRED_COOKIES = ['__Secure-1PSID', '__Secure-1PSIDTS'] as const;
|
||||
|
||||
export interface GeminiCookieFileV1 {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
cookieMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export function hasRequiredGeminiCookies(cookieMap: Record<string, string>): boolean {
|
||||
return GEMINI_REQUIRED_COOKIES.every((name) => Boolean(cookieMap[name]));
|
||||
}
|
||||
|
||||
function resolveCookieDomain(cookie: { domain?: string; url?: string }): string | null {
|
||||
const rawDomain = cookie.domain?.trim();
|
||||
if (rawDomain) {
|
||||
return rawDomain.startsWith('.') ? rawDomain.slice(1) : rawDomain;
|
||||
}
|
||||
const rawUrl = cookie.url?.trim();
|
||||
if (rawUrl) {
|
||||
try {
|
||||
return new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickCookieValue<T extends { name?: string; value?: string; domain?: string; path?: string; url?: string }>(
|
||||
cookies: T[],
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const matches = cookies.filter((cookie) => cookie.name === name && typeof cookie.value === 'string');
|
||||
if (matches.length === 0) return undefined;
|
||||
|
||||
const preferredDomain = matches.find((cookie) => {
|
||||
const domain = resolveCookieDomain(cookie);
|
||||
return domain === 'google.com' && (cookie.path ?? '/') === '/';
|
||||
});
|
||||
const googleDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? '').endsWith('google.com'));
|
||||
return (preferredDomain ?? googleDomain ?? matches[0])?.value;
|
||||
}
|
||||
|
||||
export function buildGeminiCookieMap<
|
||||
T extends { name?: string; value?: string; domain?: string; path?: string; url?: string },
|
||||
>(cookies: T[]): Record<string, string> {
|
||||
const cookieMap: Record<string, string> = {};
|
||||
for (const name of GEMINI_COOKIE_NAMES) {
|
||||
const value = pickCookieValue(cookies, name);
|
||||
if (value) cookieMap[name] = value;
|
||||
}
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
export async function readGeminiCookieMapFromDisk(options?: {
|
||||
cookiePath?: string;
|
||||
log?: GeminiWebLog;
|
||||
}): Promise<Record<string, string>> {
|
||||
const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath();
|
||||
|
||||
try {
|
||||
const raw = await readFile(cookiePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<GeminiCookieFileV1> | Record<string, unknown>;
|
||||
|
||||
const cookieMap =
|
||||
(parsed as Partial<GeminiCookieFileV1>).version === 1
|
||||
? (parsed as Partial<GeminiCookieFileV1>).cookieMap
|
||||
: (parsed as Record<string, unknown>);
|
||||
|
||||
if (!cookieMap || typeof cookieMap !== 'object') return {};
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(cookieMap)) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(normalized).length > 0) {
|
||||
options?.log?.(`[gemini-web] Loaded cookies from ${cookiePath}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code === 'ENOENT') return {};
|
||||
options?.log?.(
|
||||
`[gemini-web] Failed to read cookies from ${cookiePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeGeminiCookieMapToDisk(
|
||||
cookieMap: Record<string, string>,
|
||||
options?: { cookiePath?: string; log?: GeminiWebLog },
|
||||
): Promise<void> {
|
||||
const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath();
|
||||
await mkdir(path.dirname(cookiePath), { recursive: true });
|
||||
|
||||
const payload: GeminiCookieFileV1 = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
cookieMap,
|
||||
};
|
||||
|
||||
await writeFile(cookiePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
||||
try {
|
||||
await chmod(cookiePath, 0o600);
|
||||
} catch {
|
||||
// ignore chmod failures (e.g. on Windows)
|
||||
}
|
||||
options?.log?.(`[gemini-web] Saved cookies to ${cookiePath}`);
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import type { BrowserRunOptions, BrowserRunResult, BrowserLogger, CookieParam } from '../browser/types.js';
|
||||
import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
|
||||
import type { GeminiWebModelId } from './client.js';
|
||||
import {
|
||||
buildGeminiCookieMap,
|
||||
hasRequiredGeminiCookies,
|
||||
readGeminiCookieMapFromDisk,
|
||||
} from './cookie-store.js';
|
||||
import type { GeminiWebOptions, GeminiWebResponse } from './types.js';
|
||||
|
||||
export { hasRequiredGeminiCookies } from './cookie-store.js';
|
||||
|
||||
function estimateTokenCount(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function resolveInvocationPath(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
|
||||
}
|
||||
|
||||
function resolveGeminiWebModel(
|
||||
desiredModel: string | null | undefined,
|
||||
log?: BrowserLogger,
|
||||
): GeminiWebModelId {
|
||||
const desired = typeof desiredModel === 'string' ? desiredModel.trim() : '';
|
||||
if (!desired) return 'gemini-3-pro';
|
||||
|
||||
switch (desired) {
|
||||
case 'gemini-3-pro':
|
||||
case 'gemini-3.0-pro':
|
||||
return 'gemini-3-pro';
|
||||
case 'gemini-2.5-pro':
|
||||
return 'gemini-2.5-pro';
|
||||
case 'gemini-2.5-flash':
|
||||
return 'gemini-2.5-flash';
|
||||
default:
|
||||
if (desired.startsWith('gemini-')) {
|
||||
log?.(
|
||||
`[gemini-web] Unsupported Gemini web model "${desired}". Falling back to gemini-3-pro.`,
|
||||
);
|
||||
}
|
||||
return 'gemini-3-pro';
|
||||
}
|
||||
}
|
||||
|
||||
function buildInlineCookiesFromEnv(): CookieParam[] {
|
||||
const cookies: CookieParam[] = [];
|
||||
const psid = process.env.GEMINI_SECURE_1PSID?.trim();
|
||||
const psidts = process.env.GEMINI_SECURE_1PSIDTS?.trim();
|
||||
|
||||
if (psid) {
|
||||
cookies.push({ name: '__Secure-1PSID', value: psid, domain: 'google.com', path: '/' });
|
||||
}
|
||||
if (psidts) {
|
||||
cookies.push({ name: '__Secure-1PSIDTS', value: psidts, domain: 'google.com', path: '/' });
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function loadGeminiCookiesFromInline(
|
||||
browserConfig: BrowserRunOptions['config'],
|
||||
log?: BrowserLogger,
|
||||
): Promise<Record<string, string>> {
|
||||
const inline = browserConfig?.inlineCookies;
|
||||
if (!inline || inline.length === 0) return {};
|
||||
|
||||
const cookieMap = buildGeminiCookieMap(
|
||||
inline.filter((cookie): cookie is CookieParam => Boolean(cookie?.name && typeof cookie.value === 'string')),
|
||||
);
|
||||
|
||||
if (Object.keys(cookieMap).length > 0) {
|
||||
const source = browserConfig?.inlineCookiesSource ?? 'inline';
|
||||
log?.(`[gemini-web] Loaded Gemini cookies from inline payload (${source}): ${Object.keys(cookieMap).length} cookie(s).`);
|
||||
} else {
|
||||
log?.('[gemini-web] Inline cookie payload provided but no Gemini cookies matched.');
|
||||
}
|
||||
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
export async function loadGeminiCookies(
|
||||
browserConfig: BrowserRunOptions['config'],
|
||||
log?: BrowserLogger,
|
||||
): Promise<Record<string, string>> {
|
||||
const inlineMap = await loadGeminiCookiesFromInline(browserConfig, log);
|
||||
if (hasRequiredGeminiCookies(inlineMap)) return inlineMap;
|
||||
|
||||
const diskMap = await readGeminiCookieMapFromDisk({ log });
|
||||
const merged = { ...diskMap, ...inlineMap };
|
||||
if (hasRequiredGeminiCookies(merged)) return merged;
|
||||
|
||||
if (browserConfig?.cookieSync === false) {
|
||||
log?.('[gemini-web] Cookie sync disabled and inline cookies missing Gemini auth tokens.');
|
||||
return merged;
|
||||
}
|
||||
|
||||
log?.(
|
||||
'[gemini-web] Missing Gemini auth cookies. Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and refresh cookies.',
|
||||
);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function loadGeminiCookieMap(log?: BrowserLogger): Promise<Record<string, string>> {
|
||||
const diskMap = await readGeminiCookieMapFromDisk({ log });
|
||||
const inlineCookies = buildInlineCookiesFromEnv();
|
||||
const envMap = buildGeminiCookieMap(inlineCookies);
|
||||
return { ...diskMap, ...envMap };
|
||||
}
|
||||
|
||||
export function createGeminiWebExecutor(
|
||||
geminiOptions: GeminiWebOptions,
|
||||
): (runOptions: BrowserRunOptions) => Promise<BrowserRunResult> {
|
||||
return async (runOptions: BrowserRunOptions): Promise<BrowserRunResult> => {
|
||||
const startTime = Date.now();
|
||||
const log = runOptions.log;
|
||||
|
||||
log?.('[gemini-web] Starting Gemini web executor (TypeScript)');
|
||||
|
||||
const cookieMap = await loadGeminiCookies(runOptions.config, log);
|
||||
if (!hasRequiredGeminiCookies(cookieMap)) {
|
||||
throw new Error(
|
||||
'Gemini browser mode requires auth cookies (missing __Secure-1PSID/__Secure-1PSIDTS). Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and save cookies.',
|
||||
);
|
||||
}
|
||||
|
||||
const configTimeout =
|
||||
typeof runOptions.config?.timeoutMs === 'number' && Number.isFinite(runOptions.config.timeoutMs)
|
||||
? Math.max(1_000, runOptions.config.timeoutMs)
|
||||
: null;
|
||||
|
||||
const defaultTimeoutMs = geminiOptions.youtube
|
||||
? 240_000
|
||||
: geminiOptions.generateImage || geminiOptions.editImage
|
||||
? 300_000
|
||||
: 120_000;
|
||||
|
||||
const timeoutMs = Math.min(configTimeout ?? defaultTimeoutMs, 600_000);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const generateImagePath = resolveInvocationPath(geminiOptions.generateImage);
|
||||
const editImagePath = resolveInvocationPath(geminiOptions.editImage);
|
||||
const outputPath = resolveInvocationPath(geminiOptions.outputPath);
|
||||
const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path);
|
||||
|
||||
let prompt = runOptions.prompt;
|
||||
if (geminiOptions.aspectRatio && (generateImagePath || editImagePath)) {
|
||||
prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`;
|
||||
}
|
||||
if (geminiOptions.youtube) {
|
||||
prompt = `${prompt}\n\nYouTube video: ${geminiOptions.youtube}`;
|
||||
}
|
||||
if (generateImagePath && !editImagePath) {
|
||||
prompt = `Generate an image: ${prompt}`;
|
||||
}
|
||||
|
||||
const model: GeminiWebModelId = resolveGeminiWebModel(runOptions.config?.desiredModel, log);
|
||||
let response: GeminiWebResponse;
|
||||
|
||||
try {
|
||||
if (editImagePath) {
|
||||
const intro = await runGeminiWebWithFallback({
|
||||
prompt: 'Here is an image to edit',
|
||||
files: [editImagePath],
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const editPrompt = `Use image generation tool to ${prompt}`;
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt: editPrompt,
|
||||
files: attachmentPaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata: intro.metadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: false,
|
||||
image_count: 0,
|
||||
};
|
||||
|
||||
const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png';
|
||||
const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath, controller.signal);
|
||||
response.has_images = imageSave.saved;
|
||||
response.image_count = imageSave.imageCount;
|
||||
if (!imageSave.saved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
} else if (generateImagePath) {
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt,
|
||||
files: attachmentPaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
signal: controller.signal,
|
||||
});
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: false,
|
||||
image_count: 0,
|
||||
};
|
||||
const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath, controller.signal);
|
||||
response.has_images = imageSave.saved;
|
||||
response.image_count = imageSave.imageCount;
|
||||
if (!imageSave.saved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
} else {
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt,
|
||||
files: attachmentPaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
signal: controller.signal,
|
||||
});
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: out.images.length > 0,
|
||||
image_count: out.images.length,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const answerText = response.text ?? '';
|
||||
let answerMarkdown = answerText;
|
||||
|
||||
if (geminiOptions.showThoughts && response.thoughts) {
|
||||
answerMarkdown = `## Thinking\n\n${response.thoughts}\n\n## Response\n\n${answerText}`;
|
||||
}
|
||||
|
||||
if (response.has_images && response.image_count > 0) {
|
||||
const imagePath = generateImagePath || outputPath || 'generated.png';
|
||||
answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`;
|
||||
}
|
||||
|
||||
const tookMs = Date.now() - startTime;
|
||||
log?.(`[gemini-web] Completed in ${tookMs}ms`);
|
||||
|
||||
return {
|
||||
answerText,
|
||||
answerMarkdown,
|
||||
tookMs,
|
||||
answerTokens: estimateTokenCount(answerText),
|
||||
answerChars: answerText.length,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
import { Endpoint, ErrorCode, Headers, Model } from './constants.js';
|
||||
import { GemMixin } from './components/gem-mixin.js';
|
||||
import {
|
||||
APIError,
|
||||
AuthError,
|
||||
GeminiError,
|
||||
ImageGenerationError,
|
||||
ModelInvalid,
|
||||
TemporarilyBlocked,
|
||||
TimeoutError,
|
||||
UsageLimitExceeded,
|
||||
} from './exceptions.js';
|
||||
import { Candidate, Gem, GeneratedImage, ModelOutput, RPCData, WebImage } from './types/index.js';
|
||||
import {
|
||||
extract_json_from_response,
|
||||
get_access_token,
|
||||
get_nested_value,
|
||||
logger,
|
||||
parse_file_name,
|
||||
rotate_1psidts,
|
||||
rotate_tasks,
|
||||
fetch_with_timeout,
|
||||
sleep,
|
||||
upload_file,
|
||||
write_cookie_file,
|
||||
resolveGeminiWebCookiePath,
|
||||
} from './utils/index.js';
|
||||
|
||||
type InitOptions = {
|
||||
timeout?: number;
|
||||
auto_close?: boolean;
|
||||
close_delay?: number;
|
||||
auto_refresh?: boolean;
|
||||
refresh_interval?: number;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
type RequestKwargs = RequestInit & { timeout_ms?: number };
|
||||
|
||||
function normalize_headers(h?: HeadersInit): Record<string, string> {
|
||||
if (!h) return {};
|
||||
if (Array.isArray(h)) return Object.fromEntries(h.map(([k, v]) => [k, v]));
|
||||
if (h instanceof Headers) {
|
||||
const out: Record<string, string> = {};
|
||||
h.forEach((v, k) => {
|
||||
out[k] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
return { ...(h as Record<string, string>) };
|
||||
}
|
||||
|
||||
function collect_strings(root: unknown, accept: (s: string) => boolean, limit: number = 20): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const stack: unknown[] = [root];
|
||||
|
||||
while (stack.length > 0 && out.length < limit) {
|
||||
const v = stack.pop();
|
||||
if (typeof v === 'string') {
|
||||
if (accept(v) && !seen.has(v)) {
|
||||
seen.add(v);
|
||||
out.push(v);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
for (let i = 0; i < v.length; i++) stack.push(v[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (v && typeof v === 'object') {
|
||||
for (const val of Object.values(v as Record<string, unknown>)) stack.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export class GeminiClient extends GemMixin {
|
||||
public cookies: Record<string, string> = {};
|
||||
public proxy: string | null = null;
|
||||
public _running: boolean = false;
|
||||
public access_token: string | null = null;
|
||||
public timeout: number = 300;
|
||||
public auto_close: boolean = false;
|
||||
public close_delay: number = 300;
|
||||
public auto_refresh: boolean = true;
|
||||
public refresh_interval: number = 540;
|
||||
public kwargs: RequestInit;
|
||||
|
||||
private close_timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refresh_abort: AbortController | null = null;
|
||||
|
||||
constructor(
|
||||
secure_1psid: string | null = null,
|
||||
secure_1psidts: string | null = null,
|
||||
proxy: string | null = null,
|
||||
kwargs: RequestInit = {},
|
||||
) {
|
||||
super();
|
||||
this.proxy = proxy;
|
||||
this.kwargs = kwargs;
|
||||
|
||||
if (secure_1psid) {
|
||||
this.cookies['__Secure-1PSID'] = secure_1psid;
|
||||
if (secure_1psidts) this.cookies['__Secure-1PSIDTS'] = secure_1psidts;
|
||||
}
|
||||
}
|
||||
|
||||
async init(
|
||||
timeoutOrOpts: number | InitOptions = 300,
|
||||
auto_close: boolean = false,
|
||||
close_delay: number = 300,
|
||||
auto_refresh: boolean = true,
|
||||
refresh_interval: number = 540,
|
||||
verbose: boolean = true,
|
||||
): Promise<void> {
|
||||
const opts: InitOptions =
|
||||
typeof timeoutOrOpts === 'object'
|
||||
? timeoutOrOpts
|
||||
: { timeout: timeoutOrOpts, auto_close, close_delay, auto_refresh, refresh_interval, verbose };
|
||||
|
||||
const timeout = opts.timeout ?? 300;
|
||||
const ac = opts.auto_close ?? false;
|
||||
const cd = opts.close_delay ?? 300;
|
||||
const ar = opts.auto_refresh ?? true;
|
||||
const ri = opts.refresh_interval ?? 540;
|
||||
const vb = opts.verbose ?? true;
|
||||
|
||||
try {
|
||||
const [token, valid] = await get_access_token(this.cookies, this.proxy, vb);
|
||||
this.access_token = token;
|
||||
this.cookies = valid;
|
||||
this._running = true;
|
||||
|
||||
this.timeout = timeout;
|
||||
this.auto_close = ac;
|
||||
this.close_delay = cd;
|
||||
if (this.auto_close) await this.reset_close_task();
|
||||
|
||||
this.auto_refresh = ar;
|
||||
this.refresh_interval = ri;
|
||||
|
||||
const sid = this.cookies['__Secure-1PSID'];
|
||||
if (sid) {
|
||||
const existing = rotate_tasks.get(sid);
|
||||
if (existing && existing instanceof AbortController) existing.abort();
|
||||
rotate_tasks.delete(sid);
|
||||
}
|
||||
|
||||
if (this.auto_refresh && sid) {
|
||||
const ctl = new AbortController();
|
||||
this.refresh_abort?.abort();
|
||||
this.refresh_abort = ctl;
|
||||
rotate_tasks.set(sid, ctl);
|
||||
void this.start_auto_refresh(ctl.signal);
|
||||
}
|
||||
|
||||
await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'client').catch(() => {});
|
||||
|
||||
if (vb) logger.success('Gemini client initialized successfully.');
|
||||
} catch (e) {
|
||||
await this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async close(delay: number = 0): Promise<void> {
|
||||
if (delay > 0) await sleep(delay * 1000);
|
||||
this._running = false;
|
||||
|
||||
if (this.close_timer) {
|
||||
clearTimeout(this.close_timer);
|
||||
this.close_timer = null;
|
||||
}
|
||||
|
||||
this.refresh_abort?.abort();
|
||||
this.refresh_abort = null;
|
||||
|
||||
const sid = this.cookies['__Secure-1PSID'];
|
||||
const t = sid ? rotate_tasks.get(sid) : null;
|
||||
if (t && t instanceof AbortController) t.abort();
|
||||
if (sid) rotate_tasks.delete(sid);
|
||||
}
|
||||
|
||||
async reset_close_task(): Promise<void> {
|
||||
if (this.close_timer) {
|
||||
clearTimeout(this.close_timer);
|
||||
this.close_timer = null;
|
||||
}
|
||||
|
||||
this.close_timer = setTimeout(() => {
|
||||
void this.close(0);
|
||||
}, this.close_delay * 1000);
|
||||
this.close_timer.unref?.();
|
||||
}
|
||||
|
||||
async start_auto_refresh(signal: AbortSignal): Promise<void> {
|
||||
while (!signal.aborted) {
|
||||
let newTs: string | null = null;
|
||||
try {
|
||||
newTs = await rotate_1psidts(this.cookies, this.proxy);
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
logger.warning('AuthError: Failed to refresh cookies. Auto refresh task canceled.');
|
||||
return;
|
||||
}
|
||||
logger.warning(`Unexpected error while refreshing cookies: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
if (newTs) {
|
||||
this.cookies['__Secure-1PSIDTS'] = newTs;
|
||||
await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'refresh').catch(() => {});
|
||||
logger.debug('Cookies refreshed. New __Secure-1PSIDTS applied.');
|
||||
}
|
||||
|
||||
await sleep(this.refresh_interval * 1000, signal);
|
||||
}
|
||||
}
|
||||
|
||||
protected async _run<T>(fn: () => Promise<T>, retry: number): Promise<T> {
|
||||
try {
|
||||
if (!this._running) {
|
||||
await this.init({
|
||||
timeout: this.timeout,
|
||||
auto_close: this.auto_close,
|
||||
close_delay: this.close_delay,
|
||||
auto_refresh: this.auto_refresh,
|
||||
refresh_interval: this.refresh_interval,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
if (!this._running) {
|
||||
throw new APIError('Client initialization failed.');
|
||||
}
|
||||
}
|
||||
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
let r = retry;
|
||||
if (e instanceof ImageGenerationError) r = Math.min(1, r);
|
||||
if (e instanceof APIError && r > 0) {
|
||||
await sleep(1000);
|
||||
return await this._run(fn, r - 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generate_content(
|
||||
prompt: string,
|
||||
files: string[] | null = null,
|
||||
model: Model | string | Record<string, unknown> = Model.UNSPECIFIED,
|
||||
gem: Gem | string | null = null,
|
||||
chat: ChatSession | null = null,
|
||||
kwargs: RequestKwargs = {},
|
||||
): Promise<ModelOutput> {
|
||||
return await this._run(async () => {
|
||||
if (!prompt) throw new Error('Prompt cannot be empty.');
|
||||
|
||||
let mdl: Model;
|
||||
if (typeof model === 'string') mdl = Model.from_name(model);
|
||||
else if (model instanceof Model) mdl = model;
|
||||
else if (model && typeof model === 'object') mdl = Model.from_dict(model);
|
||||
else throw new TypeError(`'model' must be a Model instance, string, or dictionary; got ${typeof model}`);
|
||||
|
||||
const gem_id = gem instanceof Gem ? gem.id : gem;
|
||||
|
||||
if (this.auto_close) await this.reset_close_task();
|
||||
|
||||
if (!this.access_token) throw new APIError('Missing access token.');
|
||||
|
||||
const f = files?.length ? files : null;
|
||||
const uploaded =
|
||||
f &&
|
||||
(await Promise.all(
|
||||
f.map(async (p) => [[await upload_file(p, this.proxy)], parse_file_name(p)] as [string[], string]),
|
||||
));
|
||||
|
||||
const first = uploaded ? [prompt, 0, null, uploaded] : [prompt];
|
||||
const inner: unknown[] = [first, null, chat ? chat.metadata : null];
|
||||
|
||||
if (gem_id) {
|
||||
for (let i = 0; i < 16; i++) inner.push(null);
|
||||
inner.push(gem_id);
|
||||
}
|
||||
|
||||
const f_req = JSON.stringify([null, JSON.stringify(inner)]);
|
||||
const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();
|
||||
|
||||
const h0 = { ...Headers.GEMINI, ...mdl.model_header, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };
|
||||
const h1 = { ...h0, ...normalize_headers(kwargs.headers) };
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
const timeout_ms = typeof kwargs.timeout_ms === 'number' ? kwargs.timeout_ms : this.timeout * 1000;
|
||||
const { timeout_ms: _t, ...rest } = kwargs;
|
||||
res = await fetch_with_timeout(Endpoint.GENERATE, {
|
||||
method: 'POST',
|
||||
headers: h1,
|
||||
body,
|
||||
redirect: 'follow',
|
||||
...this.kwargs,
|
||||
...rest,
|
||||
timeout_ms,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new TimeoutError(
|
||||
`Generate content request timed out, please try again. If the problem persists, consider setting a higher 'timeout' value when initializing GeminiClient. (${e instanceof Error ? e.message : String(e)})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
await this.close();
|
||||
throw new APIError(`Failed to generate contents. Request failed with status code ${res.status}`);
|
||||
}
|
||||
|
||||
const txt = await res.text();
|
||||
const response_json = extract_json_from_response(txt);
|
||||
|
||||
let body_json: unknown[] | null = null;
|
||||
let body_index = 0;
|
||||
|
||||
try {
|
||||
if (!Array.isArray(response_json)) throw new Error('Invalid JSON');
|
||||
for (let part_index = 0; part_index < response_json.length; part_index++) {
|
||||
const part = response_json[part_index];
|
||||
if (!Array.isArray(part)) continue;
|
||||
const part_body = get_nested_value<string | null>(part, [2], null);
|
||||
if (!part_body) continue;
|
||||
try {
|
||||
const part_json = JSON.parse(part_body) as unknown[];
|
||||
if (get_nested_value(part_json, [4], null)) {
|
||||
body_index = part_index;
|
||||
body_json = part_json;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!body_json) throw new Error('No body');
|
||||
} catch {
|
||||
await this.close();
|
||||
try {
|
||||
const code = get_nested_value<number>(response_json, [0, 5, 2, 0, 1, 0], -1);
|
||||
if (code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
|
||||
throw new UsageLimitExceeded(
|
||||
`Failed to generate contents. Usage limit of ${mdl.model_name} model has exceeded. Please try switching to another model.`,
|
||||
);
|
||||
}
|
||||
if (code === ErrorCode.MODEL_INCONSISTENT) {
|
||||
throw new ModelInvalid(
|
||||
'Failed to generate contents. The specified model is inconsistent with the chat history. Please make sure to pass the same `model` parameter when starting a chat session with previous metadata.',
|
||||
);
|
||||
}
|
||||
if (code === ErrorCode.MODEL_HEADER_INVALID) {
|
||||
throw new ModelInvalid(
|
||||
'Failed to generate contents. The specified model is not available. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
|
||||
);
|
||||
}
|
||||
if (code === ErrorCode.IP_TEMPORARILY_BLOCKED) {
|
||||
throw new TemporarilyBlocked(
|
||||
'Failed to generate contents. Your IP address is temporarily blocked by Google. Please try using a proxy or waiting for a while.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof GeminiError) throw e;
|
||||
}
|
||||
|
||||
logger.debug(`Invalid response: ${txt.slice(0, 500)}`);
|
||||
throw new APIError('Failed to generate contents. Invalid response data received. Client will try to re-initialize on next request.');
|
||||
}
|
||||
|
||||
try {
|
||||
const candidate_list = get_nested_value<unknown[]>(body_json, [4], []);
|
||||
const out: Candidate[] = [];
|
||||
|
||||
for (let candidate_index = 0; candidate_index < candidate_list.length; candidate_index++) {
|
||||
const candidate = candidate_list[candidate_index];
|
||||
if (!Array.isArray(candidate)) continue;
|
||||
|
||||
const rcid = get_nested_value<string | null>(candidate, [0], null);
|
||||
if (!rcid) continue;
|
||||
|
||||
let text = String(get_nested_value(candidate, [1, 0], ''));
|
||||
if (/^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(text)) {
|
||||
text = String(get_nested_value(candidate, [22, 0], text));
|
||||
}
|
||||
|
||||
const thoughts = get_nested_value<string | null>(candidate, [37, 0, 0], null);
|
||||
|
||||
const web_images: WebImage[] = [];
|
||||
for (const w of get_nested_value<unknown[]>(candidate, [12, 1], [])) {
|
||||
if (!Array.isArray(w)) continue;
|
||||
const url = get_nested_value<string | null>(w, [0, 0, 0], null);
|
||||
if (!url) continue;
|
||||
web_images.push(new WebImage(url, String(get_nested_value(w, [7, 0], '')), String(get_nested_value(w, [0, 4], '')), this.proxy));
|
||||
}
|
||||
|
||||
const generated_images: GeneratedImage[] = [];
|
||||
const wants_generated =
|
||||
get_nested_value(candidate, [12, 7, 0], null) != null ||
|
||||
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text);
|
||||
|
||||
if (wants_generated) {
|
||||
let img_body: unknown[] | null = null;
|
||||
for (let part_index = body_index; part_index < (response_json as unknown[]).length; part_index++) {
|
||||
const part = (response_json as unknown[])[part_index];
|
||||
if (!Array.isArray(part)) continue;
|
||||
const part_body = get_nested_value<string | null>(part, [2], null);
|
||||
if (!part_body) continue;
|
||||
try {
|
||||
const part_json = JSON.parse(part_body) as unknown[];
|
||||
const cand = get_nested_value<unknown>(part_json, [4, candidate_index], null);
|
||||
if (!cand) continue;
|
||||
|
||||
const urls = collect_strings(cand, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 1);
|
||||
if (urls.length > 0) {
|
||||
img_body = part_json;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!img_body) {
|
||||
throw new ImageGenerationError(
|
||||
'Failed to parse generated images. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
|
||||
);
|
||||
}
|
||||
|
||||
const img_candidate = get_nested_value<unknown[]>(img_body, [4, candidate_index], []);
|
||||
const finished = get_nested_value<string | null>(img_candidate, [1, 0], null);
|
||||
if (finished) {
|
||||
text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd();
|
||||
}
|
||||
|
||||
const gen = get_nested_value<unknown[]>(img_candidate, [12, 7, 0], []);
|
||||
for (let img_index = 0; img_index < gen.length; img_index++) {
|
||||
const g = gen[img_index];
|
||||
if (!Array.isArray(g)) continue;
|
||||
const url = get_nested_value<string | null>(g, [0, 3, 3], null);
|
||||
if (!url) continue;
|
||||
const img_num = get_nested_value<number | null>(g, [3, 6], null);
|
||||
const title = img_num ? `[Generated Image ${img_num}]` : '[Generated Image]';
|
||||
const alt_list = get_nested_value<unknown[]>(g, [3, 5], []);
|
||||
const alt =
|
||||
(typeof alt_list[img_index] === 'string' ? (alt_list[img_index] as string) : null) ??
|
||||
(typeof alt_list[0] === 'string' ? (alt_list[0] as string) : '') ??
|
||||
'';
|
||||
generated_images.push(new GeneratedImage(url, title, alt, this.proxy, this.cookies));
|
||||
}
|
||||
|
||||
if (generated_images.length === 0) {
|
||||
const urls = collect_strings(img_candidate, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 4);
|
||||
for (const url of urls) {
|
||||
generated_images.push(new GeneratedImage(url, '[Generated Image]', '', this.proxy, this.cookies));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push(new Candidate({ rcid, text, thoughts, web_images, generated_images }));
|
||||
}
|
||||
|
||||
if (out.length === 0) {
|
||||
throw new GeminiError('Failed to generate contents. No output data found in response.');
|
||||
}
|
||||
|
||||
const metadata = get_nested_value<string[]>(body_json, [1], []);
|
||||
const output = new ModelOutput({ metadata, candidates: out });
|
||||
|
||||
if (chat instanceof ChatSession) chat.last_output = output;
|
||||
return output;
|
||||
} catch (e) {
|
||||
if (e instanceof GeminiError || e instanceof APIError) throw e;
|
||||
throw new APIError('Failed to parse response body. Data structure is invalid.');
|
||||
}
|
||||
}, 2);
|
||||
}
|
||||
|
||||
async generateContent(
|
||||
prompt: string,
|
||||
files?: string[] | null,
|
||||
model?: Model | string | Record<string, unknown>,
|
||||
gem?: Gem | string | null,
|
||||
chat?: ChatSession | null,
|
||||
kwargs?: RequestKwargs,
|
||||
): Promise<ModelOutput> {
|
||||
return await this.generate_content(prompt, files ?? null, model ?? Model.UNSPECIFIED, gem ?? null, chat ?? null, kwargs ?? {});
|
||||
}
|
||||
|
||||
start_chat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
|
||||
return new ChatSession(this, opts);
|
||||
}
|
||||
|
||||
startChat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
|
||||
return this.start_chat(opts);
|
||||
}
|
||||
|
||||
protected async _batch_execute(payloads: RPCData[], opts: RequestInit = {}): Promise<Response> {
|
||||
if (!this.access_token) throw new APIError('Missing access token.');
|
||||
|
||||
const f_req = JSON.stringify([payloads.map((p) => p.serialize())]);
|
||||
const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();
|
||||
|
||||
const h0 = { ...Headers.GEMINI, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };
|
||||
const h1 = { ...h0, ...normalize_headers(opts.headers) };
|
||||
|
||||
const res = await fetch_with_timeout(Endpoint.BATCH_EXEC, {
|
||||
method: 'POST',
|
||||
headers: h1,
|
||||
body,
|
||||
redirect: 'follow',
|
||||
...this.kwargs,
|
||||
...opts,
|
||||
timeout_ms: this.timeout * 1000,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
await this.close();
|
||||
throw new APIError(`Batch execution failed with status code ${res.status}`);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatSession {
|
||||
private __metadata: Array<string | null> = [null, null, null];
|
||||
public geminiclient: GeminiClient;
|
||||
private _last_output: ModelOutput | null = null;
|
||||
public model: Model | string | Record<string, unknown>;
|
||||
public gem: Gem | string | null;
|
||||
|
||||
constructor(
|
||||
geminiclient: GeminiClient,
|
||||
opts: {
|
||||
metadata?: Array<string | null>;
|
||||
cid?: string | null;
|
||||
rid?: string | null;
|
||||
rcid?: string | null;
|
||||
model?: Model | string | Record<string, unknown>;
|
||||
gem?: Gem | string | null;
|
||||
} = {},
|
||||
) {
|
||||
this.geminiclient = geminiclient;
|
||||
this.model = opts.model ?? Model.UNSPECIFIED;
|
||||
this.gem = opts.gem ?? null;
|
||||
|
||||
if (opts.metadata) this.metadata = opts.metadata;
|
||||
if (opts.cid) this.cid = opts.cid;
|
||||
if (opts.rid) this.rid = opts.rid;
|
||||
if (opts.rcid) this.rcid = opts.rcid;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `ChatSession(cid='${this.cid}', rid='${this.rid}', rcid='${this.rcid}')`;
|
||||
}
|
||||
|
||||
get last_output(): ModelOutput | null {
|
||||
return this._last_output;
|
||||
}
|
||||
|
||||
set last_output(v: ModelOutput | null) {
|
||||
this._last_output = v;
|
||||
if (v) {
|
||||
this.metadata = (v.metadata ?? []) as Array<string | null>;
|
||||
this.rcid = v.rcid;
|
||||
}
|
||||
}
|
||||
|
||||
async send_message(prompt: string, files: string[] | null = null, kwargs: RequestKwargs = {}): Promise<ModelOutput> {
|
||||
return await this.geminiclient.generate_content(prompt, files, this.model, this.gem, this, kwargs);
|
||||
}
|
||||
|
||||
async sendMessage(prompt: string, files?: string[] | null, kwargs?: RequestKwargs): Promise<ModelOutput> {
|
||||
return await this.send_message(prompt, files ?? null, kwargs ?? {});
|
||||
}
|
||||
|
||||
choose_candidate(index: number): ModelOutput {
|
||||
if (!this.last_output) throw new Error('No previous output data found in this chat session.');
|
||||
if (index >= this.last_output.candidates.length) {
|
||||
throw new Error(`Index ${index} exceeds the number of candidates in last model output.`);
|
||||
}
|
||||
this.last_output.chosen = index;
|
||||
this.rcid = this.last_output.rcid;
|
||||
return this.last_output;
|
||||
}
|
||||
|
||||
chooseCandidate(index: number): ModelOutput {
|
||||
return this.choose_candidate(index);
|
||||
}
|
||||
|
||||
get metadata(): Array<string | null> {
|
||||
return this.__metadata;
|
||||
}
|
||||
|
||||
set metadata(v: Array<string | null>) {
|
||||
if (v.length > 3) throw new Error('metadata cannot exceed 3 elements');
|
||||
this.__metadata = [null, null, null];
|
||||
for (let i = 0; i < v.length; i++) this.__metadata[i] = v[i] ?? null;
|
||||
}
|
||||
|
||||
get cid(): string | null {
|
||||
return this.__metadata[0];
|
||||
}
|
||||
|
||||
set cid(v: string | null) {
|
||||
this.__metadata[0] = v;
|
||||
}
|
||||
|
||||
get rid(): string | null {
|
||||
return this.__metadata[1];
|
||||
}
|
||||
|
||||
set rid(v: string | null) {
|
||||
this.__metadata[1] = v;
|
||||
}
|
||||
|
||||
get rcid(): string | null {
|
||||
return this.__metadata[2];
|
||||
}
|
||||
|
||||
set rcid(v: string | null) {
|
||||
this.__metadata[2] = v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { GRPC } from '../constants.js';
|
||||
import { APIError } from '../exceptions.js';
|
||||
import { Gem, GemJar, RPCData } from '../types/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { extract_json_from_response, get_nested_value } from '../utils/parsing.js';
|
||||
|
||||
export abstract class GemMixin {
|
||||
protected _gems: GemJar | null = null;
|
||||
|
||||
protected abstract _run<T>(fn: () => Promise<T>, retry: number): Promise<T>;
|
||||
protected abstract _batch_execute(payloads: RPCData[], opts?: RequestInit): Promise<Response>;
|
||||
protected abstract close(delay?: number): Promise<void>;
|
||||
|
||||
get gems(): GemJar {
|
||||
if (this._gems == null) {
|
||||
throw new Error(
|
||||
'Gems not fetched yet. Call `GeminiClient.fetch_gems()` method to fetch gems from gemini.google.com.',
|
||||
);
|
||||
}
|
||||
return this._gems;
|
||||
}
|
||||
|
||||
async fetch_gems(include_hidden: boolean = false, opts?: RequestInit): Promise<GemJar> {
|
||||
return await this._run(async () => {
|
||||
const res = await this._batch_execute(
|
||||
[
|
||||
new RPCData(GRPC.LIST_GEMS, include_hidden ? '[4]' : '[3]', 'system'),
|
||||
new RPCData(GRPC.LIST_GEMS, '[2]', 'custom'),
|
||||
],
|
||||
opts,
|
||||
);
|
||||
|
||||
let response_json: unknown;
|
||||
try {
|
||||
response_json = extract_json_from_response(await res.text());
|
||||
if (!Array.isArray(response_json)) throw new Error('Invalid response');
|
||||
} catch {
|
||||
await this.close();
|
||||
throw new APIError('Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request.');
|
||||
}
|
||||
|
||||
let predefined: unknown[] = [];
|
||||
let custom: unknown[] = [];
|
||||
|
||||
try {
|
||||
for (const part of response_json as unknown[]) {
|
||||
if (!Array.isArray(part)) continue;
|
||||
const ident = part[part.length - 1];
|
||||
const body = get_nested_value<string | null>(part, [2], null);
|
||||
if (!body) continue;
|
||||
|
||||
if (ident === 'system') {
|
||||
const parsed = JSON.parse(body) as unknown[];
|
||||
predefined = (Array.isArray(parsed) ? (parsed[2] as unknown[]) : []) ?? [];
|
||||
} else if (ident === 'custom') {
|
||||
const parsed = JSON.parse(body) as unknown[] | null;
|
||||
if (parsed) custom = (parsed[2] as unknown[]) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
if (predefined.length === 0 && custom.length === 0) throw new Error('No gems');
|
||||
} catch {
|
||||
await this.close();
|
||||
logger.debug('Invalid response while parsing gems');
|
||||
throw new APIError('Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request.');
|
||||
}
|
||||
|
||||
const entries: [string, Gem][] = [];
|
||||
|
||||
for (const gem of predefined) {
|
||||
if (!Array.isArray(gem)) continue;
|
||||
const id = String(get_nested_value(gem, [0], ''));
|
||||
if (!id) continue;
|
||||
entries.push([
|
||||
id,
|
||||
new Gem(
|
||||
id,
|
||||
String(get_nested_value(gem, [1, 0], '')),
|
||||
get_nested_value<string | null>(gem, [1, 1], null),
|
||||
get_nested_value<string | null>(gem, [2, 0], null),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
for (const gem of custom) {
|
||||
if (!Array.isArray(gem)) continue;
|
||||
const id = String(get_nested_value(gem, [0], ''));
|
||||
if (!id) continue;
|
||||
entries.push([
|
||||
id,
|
||||
new Gem(
|
||||
id,
|
||||
String(get_nested_value(gem, [1, 0], '')),
|
||||
get_nested_value<string | null>(gem, [1, 1], null),
|
||||
get_nested_value<string | null>(gem, [2, 0], null),
|
||||
false,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
this._gems = new GemJar(entries);
|
||||
return this._gems;
|
||||
}, 2);
|
||||
}
|
||||
|
||||
async create_gem(name: string, prompt: string, description: string = ''): Promise<Gem> {
|
||||
return await this._run(async () => {
|
||||
const payload = JSON.stringify([
|
||||
[
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
],
|
||||
]);
|
||||
|
||||
const res = await this._batch_execute([new RPCData(GRPC.CREATE_GEM, payload)]);
|
||||
try {
|
||||
const response_json = extract_json_from_response(await res.text()) as unknown[];
|
||||
const gem_id = JSON.parse(String((response_json[0] as unknown[])[2]))[0] as string;
|
||||
return new Gem(gem_id, name, description, prompt, false);
|
||||
} catch {
|
||||
await this.close();
|
||||
throw new APIError('Failed to create gem. Invalid response data received. Client will try to re-initialize on next request.');
|
||||
}
|
||||
}, 2);
|
||||
}
|
||||
|
||||
async update_gem(gem: Gem | string, name: string, prompt: string, description: string = ''): Promise<Gem> {
|
||||
return await this._run(async () => {
|
||||
const gem_id = typeof gem === 'string' ? gem : gem.id;
|
||||
const payload = JSON.stringify([
|
||||
gem_id,
|
||||
[
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
0,
|
||||
],
|
||||
]);
|
||||
|
||||
await this._batch_execute([new RPCData(GRPC.UPDATE_GEM, payload)]);
|
||||
return new Gem(gem_id, name, description, prompt, false);
|
||||
}, 2);
|
||||
}
|
||||
|
||||
async delete_gem(gem: Gem | string, opts?: RequestInit): Promise<void> {
|
||||
return await this._run(async () => {
|
||||
const gem_id = typeof gem === 'string' ? gem : gem.id;
|
||||
const payload = JSON.stringify([gem_id]);
|
||||
await this._batch_execute([new RPCData(GRPC.DELETE_GEM, payload)], opts);
|
||||
}, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GemMixin } from './gem-mixin.js';
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export const Endpoint = {
|
||||
GOOGLE: 'https://www.google.com',
|
||||
INIT: 'https://gemini.google.com/app',
|
||||
GENERATE:
|
||||
'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate',
|
||||
ROTATE_COOKIES: 'https://accounts.google.com/RotateCookies',
|
||||
UPLOAD: 'https://content-push.googleapis.com/upload',
|
||||
BATCH_EXEC: 'https://gemini.google.com/_/BardChatUi/data/batchexecute',
|
||||
} as const;
|
||||
|
||||
export const GRPC = {
|
||||
LIST_CHATS: 'MaZiqc',
|
||||
READ_CHAT: 'hNvQHb',
|
||||
LIST_GEMS: 'CNgdBe',
|
||||
CREATE_GEM: 'oMH3Zd',
|
||||
UPDATE_GEM: 'kHv0Vd',
|
||||
DELETE_GEM: 'UXcSJb',
|
||||
} as const;
|
||||
|
||||
export const Headers = {
|
||||
GEMINI: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
|
||||
Host: 'gemini.google.com',
|
||||
Origin: 'https://gemini.google.com',
|
||||
Referer: 'https://gemini.google.com/',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'X-Same-Domain': '1',
|
||||
},
|
||||
ROTATE_COOKIES: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
UPLOAD: {
|
||||
'Push-ID': 'feeds/mcudyrk2a4khkz',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const ErrorCode = {
|
||||
TEMPORARY_ERROR_1013: 1013,
|
||||
USAGE_LIMIT_EXCEEDED: 1037,
|
||||
MODEL_INCONSISTENT: 1050,
|
||||
MODEL_HEADER_INVALID: 1052,
|
||||
IP_TEMPORARILY_BLOCKED: 1060,
|
||||
} as const;
|
||||
|
||||
export class Model {
|
||||
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
|
||||
static readonly G_3_0_PRO = new Model(
|
||||
'gemini-3.0-pro',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]' },
|
||||
false,
|
||||
);
|
||||
static readonly G_2_5_PRO = new Model(
|
||||
'gemini-2.5-pro',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' },
|
||||
false,
|
||||
);
|
||||
static readonly G_2_5_FLASH = new Model(
|
||||
'gemini-2.5-flash',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' },
|
||||
false,
|
||||
);
|
||||
|
||||
constructor(
|
||||
public readonly model_name: string,
|
||||
public readonly model_header: Record<string, string>,
|
||||
public readonly advanced_only: boolean,
|
||||
) {}
|
||||
|
||||
static from_name(name: string): Model {
|
||||
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]) {
|
||||
if (model.model_name === name) return model;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]
|
||||
.map((m) => m.model_name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
static from_dict(model_dict: { model_name?: unknown; model_header?: unknown }): Model {
|
||||
if (!model_dict || typeof model_dict !== 'object') {
|
||||
throw new Error("When passing a custom model as a dictionary, 'model_name' and 'model_header' keys must be provided.");
|
||||
}
|
||||
|
||||
if (!('model_name' in model_dict) || !('model_header' in model_dict)) {
|
||||
throw new Error("When passing a custom model as a dictionary, 'model_name' and 'model_header' keys must be provided.");
|
||||
}
|
||||
|
||||
if (typeof model_dict.model_name !== 'string' || !model_dict.model_name.trim()) {
|
||||
throw new Error("When passing a custom model as a dictionary, 'model_name' must be a non-empty string.");
|
||||
}
|
||||
|
||||
if (!model_dict.model_header || typeof model_dict.model_header !== 'object') {
|
||||
throw new Error("When passing a custom model as a dictionary, 'model_header' must be a dictionary containing valid header strings.");
|
||||
}
|
||||
|
||||
const header: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(model_dict.model_header as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') header[k] = v;
|
||||
}
|
||||
|
||||
return new Model(model_dict.model_name, header, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export class AuthError extends Error {
|
||||
constructor(message = 'AuthError') {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(message = 'APIError') {
|
||||
super(message);
|
||||
this.name = 'APIError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageGenerationError extends APIError {
|
||||
constructor(message = 'ImageGenerationError') {
|
||||
super(message);
|
||||
this.name = 'ImageGenerationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class GeminiError extends Error {
|
||||
constructor(message = 'GeminiError') {
|
||||
super(message);
|
||||
this.name = 'GeminiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends GeminiError {
|
||||
constructor(message = 'TimeoutError') {
|
||||
super(message);
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UsageLimitExceeded extends GeminiError {
|
||||
constructor(message = 'UsageLimitExceeded') {
|
||||
super(message);
|
||||
this.name = 'UsageLimitExceeded';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelInvalid extends GeminiError {
|
||||
constructor(message = 'ModelInvalid') {
|
||||
super(message);
|
||||
this.name = 'ModelInvalid';
|
||||
}
|
||||
}
|
||||
|
||||
export class TemporarilyBlocked extends GeminiError {
|
||||
constructor(message = 'TemporarilyBlocked') {
|
||||
super(message);
|
||||
this.name = 'TemporarilyBlocked';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { GeminiClient, ChatSession } from './client.js';
|
||||
|
||||
export * from './exceptions.js';
|
||||
export * from './types/index.js';
|
||||
export * from './constants.js';
|
||||
export { logger, set_log_level, setLogLevel } from './utils/logger.js';
|
||||
export * as utils from './utils/index.js';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { GeneratedImage, type Image, WebImage } from './image.js';
|
||||
|
||||
function decode_html(s: string | null | undefined): string | null | undefined {
|
||||
if (s == null) return s;
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)));
|
||||
}
|
||||
|
||||
export class Candidate {
|
||||
public rcid: string;
|
||||
public text: string;
|
||||
public thoughts: string | null;
|
||||
public web_images: WebImage[];
|
||||
public generated_images: GeneratedImage[];
|
||||
|
||||
constructor(params: {
|
||||
rcid: string;
|
||||
text: string;
|
||||
thoughts?: string | null;
|
||||
web_images?: WebImage[];
|
||||
generated_images?: GeneratedImage[];
|
||||
}) {
|
||||
this.rcid = params.rcid;
|
||||
this.text = decode_html(params.text) ?? '';
|
||||
this.thoughts = decode_html(params.thoughts) ?? null;
|
||||
this.web_images = params.web_images ?? [];
|
||||
this.generated_images = params.generated_images ?? [];
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
get images(): Image[] {
|
||||
return [...this.web_images, ...this.generated_images];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export class Gem {
|
||||
constructor(
|
||||
public id: string,
|
||||
public name: string,
|
||||
public description: string | null,
|
||||
public prompt: string | null,
|
||||
public predefined: boolean,
|
||||
) {}
|
||||
|
||||
toString(): string {
|
||||
return `Gem(id='${this.id}', name='${this.name}', description='${this.description}', prompt='${this.prompt}', predefined=${this.predefined})`;
|
||||
}
|
||||
}
|
||||
|
||||
export class GemJar implements Iterable<Gem> {
|
||||
private m = new Map<string, Gem>();
|
||||
|
||||
constructor(entries?: Iterable<[string, Gem]>) {
|
||||
if (entries) for (const [id, gem] of entries) this.m.set(id, gem);
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<Gem> {
|
||||
return this.m.values();
|
||||
}
|
||||
|
||||
entries(): IterableIterator<[string, Gem]> {
|
||||
return this.m.entries();
|
||||
}
|
||||
|
||||
values(): IterableIterator<Gem> {
|
||||
return this.m.values();
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.m.has(id);
|
||||
}
|
||||
|
||||
set(id: string, gem: Gem): this {
|
||||
this.m.set(id, gem);
|
||||
return this;
|
||||
}
|
||||
|
||||
get(id?: string | null, name?: string | null, def: Gem | null = null): Gem | null {
|
||||
if (id == null && name == null) {
|
||||
throw new Error('At least one of gem id or name must be provided.');
|
||||
}
|
||||
|
||||
if (id != null) {
|
||||
const g = this.m.get(id) ?? null;
|
||||
if (!g) return def;
|
||||
if (name != null) return g.name === name ? g : def;
|
||||
return g;
|
||||
}
|
||||
|
||||
if (name != null) {
|
||||
for (const g of this.m.values()) {
|
||||
if (g.name === name) return g;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
filter(predefined: boolean | null = null, name: string | null = null): GemJar {
|
||||
const out: [string, Gem][] = [];
|
||||
for (const [id, gem] of this.m.entries()) {
|
||||
if (predefined != null && gem.predefined !== predefined) continue;
|
||||
if (name != null && gem.name !== name) continue;
|
||||
out.push([id, gem]);
|
||||
}
|
||||
return new GemJar(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export class RPCData {
|
||||
constructor(
|
||||
public rpcid: string,
|
||||
public payload: string,
|
||||
public identifier: string = 'generic',
|
||||
) {}
|
||||
|
||||
toString(): string {
|
||||
return `GRPC(rpcid='${this.rpcid}', payload='${this.payload}', identifier='${this.identifier}')`;
|
||||
}
|
||||
|
||||
serialize(): unknown[] {
|
||||
return [this.rpcid, this.payload, null, this.identifier];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import path from 'node:path';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { cookie_header, fetch_with_timeout } from '../utils/http.js';
|
||||
|
||||
export class Image {
|
||||
constructor(
|
||||
public url: string,
|
||||
public title = '[Image]',
|
||||
public alt = '',
|
||||
public proxy: string | null = null,
|
||||
) {}
|
||||
|
||||
toString(): string {
|
||||
const u = this.url.length <= 20 ? this.url : `${this.url.slice(0, 8)}...${this.url.slice(-12)}`;
|
||||
return `Image(title='${this.title}', alt='${this.alt}', url='${u}')`;
|
||||
}
|
||||
|
||||
async save(
|
||||
p: string = 'temp',
|
||||
filename: string | null = null,
|
||||
cookies: Record<string, string> | null = null,
|
||||
verbose: boolean = false,
|
||||
skip_invalid_filename: boolean = false,
|
||||
): Promise<string | null> {
|
||||
filename = filename ?? this.url.split('/').pop()?.split('?')[0] ?? 'image';
|
||||
const m = filename.match(/^(.*\.\w+)/);
|
||||
if (m) filename = m[1]!;
|
||||
else {
|
||||
if (verbose) logger.warning(`Invalid filename: ${filename}`);
|
||||
if (skip_invalid_filename) return null;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
Referer: 'https://gemini.google.com/',
|
||||
};
|
||||
if (cookies) headers.Cookie = cookie_header(cookies);
|
||||
|
||||
let url = this.url;
|
||||
let res: Response | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
res = await fetch_with_timeout(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
timeout_ms: 30_000,
|
||||
});
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const loc = res.headers.get('location');
|
||||
if (!loc) break;
|
||||
url = new URL(loc, url).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (!res) throw new Error('Image download failed: no response');
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Error downloading image: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const ct = res.headers.get('content-type');
|
||||
if (ct && !ct.includes('image')) {
|
||||
logger.warning(`Content type of ${filename} is not image, but ${ct}.`);
|
||||
}
|
||||
|
||||
const dir = path.resolve(p);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const dest = path.join(dir, filename);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await writeFile(dest, buf);
|
||||
|
||||
if (verbose) logger.info(`Image saved as ${dest}`);
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
|
||||
export class WebImage extends Image {}
|
||||
|
||||
export class GeneratedImage extends Image {
|
||||
constructor(
|
||||
url: string,
|
||||
title: string,
|
||||
alt: string,
|
||||
proxy: string | null,
|
||||
public cookies: Record<string, string>,
|
||||
) {
|
||||
super(url, title, alt, proxy);
|
||||
if (!cookies || Object.keys(cookies).length === 0) {
|
||||
throw new Error('GeneratedImage is designed to be initialized with same cookies as GeminiClient.');
|
||||
}
|
||||
}
|
||||
|
||||
async save(
|
||||
p: string = 'temp',
|
||||
filename: string | null = null,
|
||||
cookies: Record<string, string> | null = null,
|
||||
verbose: boolean = false,
|
||||
skip_invalid_filename: boolean = false,
|
||||
full_size: boolean = true,
|
||||
): Promise<string | null> {
|
||||
const u = full_size ? `${this.url}=s2048` : this.url;
|
||||
const f = filename ?? `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}_${u.slice(-10)}.png`;
|
||||
const img = new Image(u, this.title, this.alt, this.proxy);
|
||||
return await img.save(p, f, cookies ?? this.cookies, verbose, skip_invalid_filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { Candidate } from './candidate.js';
|
||||
export { Gem, GemJar } from './gem.js';
|
||||
export { RPCData } from './grpc.js';
|
||||
export { GeneratedImage, Image, WebImage } from './image.js';
|
||||
export { ModelOutput } from './modeloutput.js';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Image } from './image.js';
|
||||
import type { Candidate } from './candidate.js';
|
||||
|
||||
export class ModelOutput {
|
||||
public metadata: string[];
|
||||
public candidates: Candidate[];
|
||||
public chosen: number;
|
||||
|
||||
constructor(params: { metadata: string[]; candidates: Candidate[]; chosen?: number }) {
|
||||
this.metadata = params.metadata;
|
||||
this.candidates = params.candidates;
|
||||
this.chosen = params.chosen ?? 0;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
get text(): string {
|
||||
return this.candidates[this.chosen]?.text ?? '';
|
||||
}
|
||||
|
||||
get thoughts(): string | null {
|
||||
return this.candidates[this.chosen]?.thoughts ?? null;
|
||||
}
|
||||
|
||||
get images(): Image[] {
|
||||
return this.candidates[this.chosen]?.images ?? [];
|
||||
}
|
||||
|
||||
get rcid(): string {
|
||||
return this.candidates[this.chosen]?.rcid ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
export type CookieMap = Record<string, string>;
|
||||
|
||||
export type CookieFileData =
|
||||
| {
|
||||
cookies: CookieMap;
|
||||
updated_at: number;
|
||||
source?: string;
|
||||
}
|
||||
| {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
cookieMap: CookieMap;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export async function read_cookie_file(p: string = resolveGeminiWebCookiePath()): Promise<CookieMap | null> {
|
||||
try {
|
||||
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return null;
|
||||
const raw = await readFile(p, 'utf8');
|
||||
const data = JSON.parse(raw) as unknown;
|
||||
|
||||
if (data && typeof data === 'object' && 'cookies' in (data as any)) {
|
||||
const cookies = (data as any).cookies as unknown;
|
||||
if (cookies && typeof cookies === 'object') {
|
||||
const out: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(cookies as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object' && 'cookieMap' in (data as any)) {
|
||||
const cookies = (data as any).cookieMap as unknown;
|
||||
if (cookies && typeof cookies === 'object') {
|
||||
const out: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(cookies as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const out: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function write_cookie_file(
|
||||
cookies: CookieMap,
|
||||
p: string = resolveGeminiWebCookiePath(),
|
||||
source?: string,
|
||||
): Promise<void> {
|
||||
const dir = path.dirname(p);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const payload: CookieFileData = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
cookieMap: cookies,
|
||||
source,
|
||||
};
|
||||
await writeFile(p, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export const readCookieFile = read_cookie_file;
|
||||
export const writeCookieFile = write_cookie_file;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { APIError, ImageGenerationError } from '../exceptions.js';
|
||||
import { sleep } from './http.js';
|
||||
|
||||
export function running(retry: number = 0) {
|
||||
return <TArgs extends unknown[], TResult>(
|
||||
fn: (client: any, ...args: TArgs) => Promise<TResult>,
|
||||
): ((client: any, ...args: TArgs) => Promise<TResult>) => {
|
||||
const wrap = async (client: any, ...args: TArgs): Promise<TResult> => {
|
||||
try {
|
||||
if (!client?._running) {
|
||||
await client.init?.({
|
||||
timeout: client.timeout,
|
||||
auto_close: client.auto_close,
|
||||
close_delay: client.close_delay,
|
||||
auto_refresh: client.auto_refresh,
|
||||
refresh_interval: client.refresh_interval,
|
||||
verbose: false,
|
||||
});
|
||||
}
|
||||
return await fn(client, ...args);
|
||||
} catch (e) {
|
||||
let r = retry;
|
||||
if (e instanceof ImageGenerationError) r = Math.min(1, r);
|
||||
if (e instanceof APIError && r > 0) {
|
||||
await sleep(1000);
|
||||
return await running(r - 1)(fn)(client, ...args);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
return wrap;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { Endpoint, Headers } from '../constants.js';
|
||||
import { AuthError } from '../exceptions.js';
|
||||
import { cookie_header, extract_set_cookie_value, fetch_with_timeout } from './http.js';
|
||||
import { logger } from './logger.js';
|
||||
import { read_cookie_file, write_cookie_file } from './cookie-file.js';
|
||||
import { resolveGeminiWebDataDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
import { load_browser_cookies } from './load-browser-cookies.js';
|
||||
|
||||
async function send_request(cookies: Record<string, string>, verbose: boolean): Promise<[string, Record<string, string>]> {
|
||||
const res = await fetch_with_timeout(Endpoint.INIT, {
|
||||
method: 'GET',
|
||||
headers: { ...Headers.GEMINI, Cookie: cookie_header(cookies) },
|
||||
redirect: 'follow',
|
||||
timeout_ms: 30_000,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Init failed: ${res.status} ${res.statusText}`);
|
||||
const text = await res.text();
|
||||
const m = text.match(/\"SNlM0e\":\"(.*?)\"/);
|
||||
if (!m) throw new Error('Missing SNlM0e in response');
|
||||
if (verbose) logger.debug('Init succeeded. Initializing client...');
|
||||
return [m[1]!, cookies];
|
||||
}
|
||||
|
||||
function merge_cookie_maps(...maps: Array<Record<string, string> | null | undefined>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const m of maps) {
|
||||
if (!m) continue;
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
if (typeof v === 'string' && v.length > 0) out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function read_cached_1psidts_file(dir: string, sid: string): string | null {
|
||||
try {
|
||||
const p = path.join(dir, `.cached_1psidts_${sid}.txt`);
|
||||
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return null;
|
||||
const v = fs.readFileSync(p, 'utf8').trim();
|
||||
return v || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function list_cached_1psidts(dir: string): Array<{ sid: string; sidts: string }> {
|
||||
const out: Array<{ sid: string; sidts: string }> = [];
|
||||
try {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return out;
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
if (!f.startsWith('.cached_1psidts_') || !f.endsWith('.txt')) continue;
|
||||
const sid = f.slice('.cached_1psidts_'.length, -'.txt'.length);
|
||||
if (!sid) continue;
|
||||
const sidts = read_cached_1psidts_file(dir, sid);
|
||||
if (sidts) out.push({ sid, sidts });
|
||||
}
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetch_google_extra_cookies(proxy: string | null, verbose: boolean): Promise<Record<string, string>> {
|
||||
void proxy;
|
||||
try {
|
||||
const res = await fetch_with_timeout(Endpoint.GOOGLE, { timeout_ms: 15_000 });
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
const nid = extract_set_cookie_value(setCookie, 'NID');
|
||||
if (nid) return { NID: nid };
|
||||
} catch (e) {
|
||||
if (verbose) logger.debug(`Skipping google.com preflight: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function get_access_token(
|
||||
base_cookies: Record<string, string>,
|
||||
proxy: string | null = null,
|
||||
verbose: boolean = false,
|
||||
): Promise<[string, Record<string, string>]> {
|
||||
const extra = await fetch_google_extra_cookies(proxy, verbose);
|
||||
|
||||
const cacheDir = resolveGeminiWebDataDir();
|
||||
const candidates: Record<string, string>[] = [];
|
||||
|
||||
const cookieFilePath = resolveGeminiWebCookiePath();
|
||||
const cachedFile = await read_cookie_file(cookieFilePath);
|
||||
const forceLogin = !!(process.env.GEMINI_WEB_LOGIN?.trim() || process.env.GEMINI_WEB_FORCE_LOGIN?.trim());
|
||||
const shouldUseChromeFirst = forceLogin || (!cachedFile && !base_cookies['__Secure-1PSID'] && !base_cookies['__Secure-1PSIDTS']);
|
||||
|
||||
if (shouldUseChromeFirst) {
|
||||
try {
|
||||
const browser = await load_browser_cookies('google.com', verbose);
|
||||
for (const cookies of Object.values(browser)) {
|
||||
candidates.push(merge_cookie_maps(extra, cookies));
|
||||
}
|
||||
} catch (e) {
|
||||
if (verbose) logger.warning(`Failed to load cookies via Chrome CDP: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (base_cookies['__Secure-1PSID'] && base_cookies['__Secure-1PSIDTS']) {
|
||||
candidates.push(merge_cookie_maps(extra, base_cookies));
|
||||
} else if (verbose) {
|
||||
logger.debug('Skipping loading base cookies. Either __Secure-1PSID or __Secure-1PSIDTS is not provided.');
|
||||
}
|
||||
|
||||
if (cachedFile) {
|
||||
candidates.push(merge_cookie_maps(extra, cachedFile));
|
||||
}
|
||||
|
||||
if (base_cookies['__Secure-1PSID'] && !base_cookies['__Secure-1PSIDTS']) {
|
||||
const sid = base_cookies['__Secure-1PSID'];
|
||||
const sidts = read_cached_1psidts_file(cacheDir, sid);
|
||||
if (sidts) {
|
||||
candidates.push(merge_cookie_maps(extra, base_cookies, { '__Secure-1PSIDTS': sidts }));
|
||||
} else if (verbose) {
|
||||
logger.debug('Skipping loading cached cookies. Cache file not found or empty.');
|
||||
}
|
||||
} else if (!base_cookies['__Secure-1PSID']) {
|
||||
const caches = list_cached_1psidts(cacheDir);
|
||||
for (const c of caches) {
|
||||
candidates.push(merge_cookie_maps(extra, { '__Secure-1PSID': c.sid, '__Secure-1PSIDTS': c.sidts }));
|
||||
}
|
||||
if (caches.length === 0 && verbose) {
|
||||
logger.debug('Skipping loading cached cookies. Cookies will be cached after successful initialization.');
|
||||
}
|
||||
}
|
||||
|
||||
const unique: Record<string, string>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const c of candidates) {
|
||||
const key = `${c['__Secure-1PSID'] ?? ''}:${c['__Secure-1PSIDTS'] ?? ''}:${c.NID ?? ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(c);
|
||||
}
|
||||
|
||||
const try_candidates = async (): Promise<[string, Record<string, string>]> => {
|
||||
if (unique.length === 0) throw new Error('no candidates');
|
||||
const attempts = unique.map(async (c, i) => {
|
||||
try {
|
||||
if (verbose) logger.debug(`Init attempt (${i + 1}/${unique.length})...`);
|
||||
return await send_request(c, verbose);
|
||||
} catch (e) {
|
||||
if (verbose) logger.debug(`Init attempt (${i + 1}/${unique.length}) failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
return (await Promise.any(attempts)) as [string, Record<string, string>];
|
||||
};
|
||||
|
||||
try {
|
||||
const [token, cookies] = await try_candidates();
|
||||
await write_cookie_file(cookies, resolveGeminiWebCookiePath(), 'init').catch(() => {});
|
||||
return [token, cookies];
|
||||
} catch {
|
||||
if (verbose) logger.debug('Cookie attempts failed. Falling back to Chrome CDP cookie load...');
|
||||
}
|
||||
|
||||
const browser = await load_browser_cookies('google.com', verbose);
|
||||
let valid = 0;
|
||||
for (const cookies of Object.values(browser)) {
|
||||
if (cookies['__Secure-1PSID']) valid++;
|
||||
if (base_cookies['__Secure-1PSID'] && cookies['__Secure-1PSID'] && cookies['__Secure-1PSID'] !== base_cookies['__Secure-1PSID']) {
|
||||
if (verbose) logger.debug('Skipping loaded browser cookies: __Secure-1PSID does not match the one provided.');
|
||||
continue;
|
||||
}
|
||||
unique.push(merge_cookie_maps(extra, cookies));
|
||||
}
|
||||
|
||||
if (valid === 0) {
|
||||
throw new AuthError(
|
||||
'No valid cookies available for initialization. Please pass __Secure-1PSID and __Secure-1PSIDTS manually.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [token, cookies] = await try_candidates();
|
||||
await write_cookie_file(cookies, resolveGeminiWebCookiePath(), 'init').catch(() => {});
|
||||
return [token, cookies];
|
||||
} catch {
|
||||
throw new AuthError(
|
||||
`Failed to initialize client. SECURE_1PSIDTS could get expired frequently, please make sure cookie values are up to date. (Failed initialization attempts: ${unique.length})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const getAccessToken = get_access_token;
|
||||
@@ -0,0 +1,57 @@
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(t);
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function cookie_header(cookies: Record<string, string>): string {
|
||||
return Object.entries(cookies)
|
||||
.filter(([, v]) => typeof v === 'string' && v.length > 0)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export const cookieHeader = cookie_header;
|
||||
|
||||
export function extract_set_cookie_value(setCookie: string | null, name: string): string | null {
|
||||
if (!setCookie) return null;
|
||||
const re = new RegExp(`(?:^|[;,\\s])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}=([^;]+)`, 'i');
|
||||
const m = setCookie.match(re);
|
||||
if (!m) return null;
|
||||
return m[1] ?? null;
|
||||
}
|
||||
|
||||
export async function fetch_with_timeout(
|
||||
url: string,
|
||||
init: RequestInit & { timeout_ms?: number } = {},
|
||||
): Promise<Response> {
|
||||
const { timeout_ms, ...rest } = init;
|
||||
if (!timeout_ms || timeout_ms <= 0) return fetch(url, rest);
|
||||
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), timeout_ms);
|
||||
try {
|
||||
return await fetch(url, { ...rest, signal: ctl.signal });
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchWithTimeout = fetch_with_timeout;
|
||||
@@ -0,0 +1,20 @@
|
||||
export { running } from './decorators.js';
|
||||
export { get_access_token, getAccessToken } from './get-access-token.js';
|
||||
export { load_browser_cookies, loadBrowserCookies } from './load-browser-cookies.js';
|
||||
export { logger, set_log_level, setLogLevel } from './logger.js';
|
||||
export { extract_json_from_response, extractJsonFromResponse, get_nested_value, getNestedValue } from './parsing.js';
|
||||
export { rotate_1psidts, rotate1psidts } from './rotate-1psidts.js';
|
||||
export { upload_file, uploadFile, parse_file_name, parseFileName } from './upload-file.js';
|
||||
export { read_cookie_file, readCookieFile, write_cookie_file, writeCookieFile } from './cookie-file.js';
|
||||
export {
|
||||
resolveUserDataRoot,
|
||||
resolveGeminiWebChromeProfileDir,
|
||||
resolveGeminiWebCookiePath,
|
||||
resolveGeminiWebDataDir,
|
||||
resolveGeminiWebSessionPath,
|
||||
resolveGeminiWebSessionsDir,
|
||||
} from './paths.js';
|
||||
export { cookie_header, cookieHeader, fetch_with_timeout, fetchWithTimeout, sleep } from './http.js';
|
||||
|
||||
export const rotate_tasks = new Map<string, unknown>();
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import process from 'node:process';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { fetch_with_timeout, sleep } from './http.js';
|
||||
import { read_cookie_file, type CookieMap, write_cookie_file } from './cookie-file.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
||||
>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (msg.id) {
|
||||
const p = this.pending.get(msg.id);
|
||||
if (p) {
|
||||
this.pending.delete(msg.id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
if (msg.error?.message) p.reject(new Error(msg.error.message));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, p] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
p.reject(new Error('CDP connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error('CDP connection failed.'));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, opts?: CdpSendOptions): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const msg: Record<string, unknown> = { id, method };
|
||||
if (params) msg.params = params;
|
||||
if (opts?.sessionId) msg.sessionId = opts.sessionId;
|
||||
|
||||
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
||||
const out = await new Promise<unknown>((resolve, reject) => {
|
||||
const t =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer: t });
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
});
|
||||
return out as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function get_free_port(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
srv.on('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
srv.close(() => reject(new Error('Unable to allocate a free TCP port.')));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function find_chrome_executable(): string | null {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
candidates.push(
|
||||
'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function wait_for_chrome_debug_port(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch_with_timeout(`http://127.0.0.1:${port}/json/version`, { timeout_ms: 5_000 });
|
||||
if (!res.ok) throw new Error(`status=${res.status}`);
|
||||
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
||||
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error('Chrome debug port not ready');
|
||||
}
|
||||
|
||||
async function launch_chrome(profileDir: string, port: number): Promise<ChildProcess> {
|
||||
const chrome = find_chrome_executable();
|
||||
if (!chrome) throw new Error('Chrome executable not found.');
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-popup-blocking',
|
||||
'https://gemini.google.com/app',
|
||||
];
|
||||
|
||||
return spawn(chrome, args, { stdio: 'ignore' });
|
||||
}
|
||||
|
||||
async function fetch_google_cookies_via_cdp(
|
||||
profileDir: string,
|
||||
timeoutMs: number,
|
||||
verbose: boolean,
|
||||
): Promise<CookieMap> {
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await get_free_port();
|
||||
const chrome = await launch_chrome(profileDir, port);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
try {
|
||||
const wsUrl = await wait_for_chrome_debug_port(port, 30_000);
|
||||
cdp = await CdpConnection.connect(wsUrl, 15_000);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', {
|
||||
url: 'https://gemini.google.com/app',
|
||||
newWindow: true,
|
||||
});
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });
|
||||
await cdp.send('Network.enable', {}, { sessionId });
|
||||
|
||||
if (verbose) {
|
||||
logger.info('Chrome opened. If needed, complete Google login in the window. Waiting for cookies...');
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let last: CookieMap = {};
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { cookies } = await cdp.send<{ cookies: Array<{ name: string; value: string }> }>(
|
||||
'Network.getCookies',
|
||||
{ urls: ['https://gemini.google.com/', 'https://accounts.google.com/', 'https://www.google.com/'] },
|
||||
{ sessionId, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const m: CookieMap = {};
|
||||
for (const c of cookies) {
|
||||
if (c?.name && typeof c.value === 'string') m[c.name] = c.value;
|
||||
}
|
||||
|
||||
last = m;
|
||||
if (m['__Secure-1PSID'] && (m['__Secure-1PSIDTS'] || Date.now() - start > 10_000)) {
|
||||
return m;
|
||||
}
|
||||
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Google cookies. Last keys: ${Object.keys(last).join(', ')}`);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try {
|
||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
try {
|
||||
chrome.kill('SIGTERM');
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill('SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
export async function load_browser_cookies(domain_name: string = '', verbose: boolean = true): Promise<Record<string, CookieMap>> {
|
||||
const force = process.env.GEMINI_WEB_LOGIN?.trim() || process.env.GEMINI_WEB_FORCE_LOGIN?.trim();
|
||||
if (!force) {
|
||||
const cached = await read_cookie_file();
|
||||
if (cached) return { chrome: cached };
|
||||
}
|
||||
|
||||
const profileDir = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || resolveGeminiWebChromeProfileDir();
|
||||
const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
|
||||
|
||||
const filtered: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(cookies)) {
|
||||
if (typeof v === 'string' && v.length > 0) filtered[k] = v;
|
||||
}
|
||||
|
||||
await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp');
|
||||
void domain_name;
|
||||
return { chrome: filtered };
|
||||
}
|
||||
|
||||
export const loadBrowserCookies = load_browser_cookies;
|
||||
@@ -0,0 +1,42 @@
|
||||
export type LogLevel = 'TRACE' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | number;
|
||||
|
||||
const lvl: Record<Exclude<LogLevel, number>, number> = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
INFO: 2,
|
||||
WARNING: 3,
|
||||
ERROR: 4,
|
||||
CRITICAL: 5,
|
||||
};
|
||||
|
||||
let cur = lvl.INFO;
|
||||
|
||||
function toNum(level: LogLevel): number {
|
||||
if (typeof level === 'number') return level;
|
||||
return lvl[level] ?? lvl.INFO;
|
||||
}
|
||||
|
||||
export function set_log_level(level: LogLevel): void {
|
||||
cur = toNum(level);
|
||||
}
|
||||
|
||||
export const setLogLevel = set_log_level;
|
||||
|
||||
function emit(level: Exclude<LogLevel, number>, args: unknown[]): void {
|
||||
if (lvl[level] < cur) return;
|
||||
const prefix = `[gemini_webapi] ${level}:`;
|
||||
|
||||
if (level === 'WARNING') console.warn(prefix, ...args);
|
||||
else if (level === 'ERROR' || level === 'CRITICAL') console.error(prefix, ...args);
|
||||
else console.log(prefix, ...args);
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
trace: (...args: unknown[]) => emit('TRACE', args),
|
||||
debug: (...args: unknown[]) => emit('DEBUG', args),
|
||||
info: (...args: unknown[]) => emit('INFO', args),
|
||||
warning: (...args: unknown[]) => emit('WARNING', args),
|
||||
error: (...args: unknown[]) => emit('ERROR', args),
|
||||
success: (...args: unknown[]) => emit('INFO', args),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export function get_nested_value<T = unknown>(data: unknown, path: number[], def?: T): T {
|
||||
let cur: unknown = data;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const k = path[i]!;
|
||||
if (!Array.isArray(cur)) {
|
||||
logger.debug(`Safe navigation: path ${JSON.stringify(path)} ended at index ${i} (key '${k}'), returning default.`);
|
||||
return def as T;
|
||||
}
|
||||
cur = cur[k];
|
||||
if (cur === undefined) {
|
||||
logger.debug(`Safe navigation: path ${JSON.stringify(path)} ended at index ${i} (key '${k}'), returning default.`);
|
||||
return def as T;
|
||||
}
|
||||
}
|
||||
|
||||
if (cur == null && def !== undefined) return def as T;
|
||||
return cur as T;
|
||||
}
|
||||
|
||||
export function extract_json_from_response(text: string): unknown {
|
||||
if (typeof text !== 'string') {
|
||||
throw new TypeError(`Input text is expected to be a string, got ${typeof text} instead.`);
|
||||
}
|
||||
|
||||
let last: unknown = undefined;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
last = JSON.parse(trimmed) as unknown;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (last === undefined) {
|
||||
throw new Error('Could not find a valid JSON object or array in the response.');
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
|
||||
export const extractJsonFromResponse = extract_json_from_response;
|
||||
export const getNestedValue = get_nested_value;
|
||||
+10
@@ -34,3 +34,13 @@ export function resolveGeminiWebChromeProfileDir(): string {
|
||||
if (override) return path.resolve(override);
|
||||
return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME);
|
||||
}
|
||||
|
||||
export function resolveGeminiWebSessionsDir(): string {
|
||||
return path.join(resolveGeminiWebDataDir(), 'sessions');
|
||||
}
|
||||
|
||||
export function resolveGeminiWebSessionPath(name: string): string {
|
||||
const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
return path.join(resolveGeminiWebSessionsDir(), `${sanitized}.json`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { Endpoint, Headers } from '../constants.js';
|
||||
import { AuthError } from '../exceptions.js';
|
||||
import { cookie_header, extract_set_cookie_value, fetch_with_timeout } from './http.js';
|
||||
import { resolveGeminiWebDataDir } from './paths.js';
|
||||
|
||||
export async function rotate_1psidts(cookies: Record<string, string>, _proxy?: string | null): Promise<string | null> {
|
||||
const p = resolveGeminiWebDataDir();
|
||||
await mkdir(p, { recursive: true });
|
||||
|
||||
const sid = cookies['__Secure-1PSID'];
|
||||
if (!sid) throw new Error('Missing __Secure-1PSID cookie.');
|
||||
|
||||
const cachePath = path.join(p, `.cached_1psidts_${sid}.txt`);
|
||||
|
||||
try {
|
||||
const st = fs.statSync(cachePath);
|
||||
if (Date.now() - st.mtimeMs <= 60_000) return null;
|
||||
} catch {}
|
||||
|
||||
const res = await fetch_with_timeout(Endpoint.ROTATE_COOKIES, {
|
||||
method: 'POST',
|
||||
headers: { ...Headers.ROTATE_COOKIES, Cookie: cookie_header(cookies) },
|
||||
body: '[000,"-0000000000000000000"]',
|
||||
redirect: 'follow',
|
||||
timeout_ms: 30_000,
|
||||
});
|
||||
|
||||
if (res.status === 401) throw new AuthError('Failed to refresh cookies (401).');
|
||||
if (!res.ok) throw new Error(`RotateCookies failed: ${res.status} ${res.statusText}`);
|
||||
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
const v = extract_set_cookie_value(setCookie, '__Secure-1PSIDTS');
|
||||
if (v) {
|
||||
await writeFile(cachePath, v, 'utf8');
|
||||
return v;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const rotate1psidts = rotate_1psidts;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { Endpoint, Headers } from '../constants.js';
|
||||
|
||||
export async function upload_file(file: string, _proxy?: string | null): Promise<string> {
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
throw new Error(`${file} is not a valid file.`);
|
||||
}
|
||||
|
||||
const filename = path.basename(file);
|
||||
const content = await readFile(file);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([content]), filename);
|
||||
|
||||
const res = await fetch(Endpoint.UPLOAD, {
|
||||
method: 'POST',
|
||||
headers: { ...Headers.UPLOAD },
|
||||
body: form,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export function parse_file_name(file: string): string {
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
throw new Error(`${file} is not a valid file.`);
|
||||
}
|
||||
return path.basename(file);
|
||||
}
|
||||
|
||||
export const uploadFile = upload_file;
|
||||
export const parseFileName = parse_file_name;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env -S npx -y bun
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
import { getGeminiCookieMapViaChrome } from './chrome-auth.js';
|
||||
import { writeGeminiCookieMapToDisk } from './cookie-store.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cookiePath = resolveGeminiWebCookiePath();
|
||||
const profileDir = resolveGeminiWebChromeProfileDir();
|
||||
|
||||
const log = (msg: string) => console.log(msg);
|
||||
const cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log });
|
||||
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log });
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export { createGeminiWebExecutor } from './executor.js';
|
||||
export type { GeminiWebOptions, GeminiWebResponse } from './types.js';
|
||||
@@ -1,371 +1,490 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { fetchGeminiAccessToken, runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
|
||||
import { getGeminiCookieMapViaChrome } from './chrome-auth.js';
|
||||
import {
|
||||
hasRequiredGeminiCookies,
|
||||
readGeminiCookieMapFromDisk,
|
||||
writeGeminiCookieMapToDisk,
|
||||
} from './cookie-store.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
import { GeminiClient, GeneratedImage, Model, type ModelOutput } from './gemini-webapi/index.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath, resolveGeminiWebSessionPath, resolveGeminiWebSessionsDir } from './gemini-webapi/utils/index.js';
|
||||
|
||||
function printUsage(exitCode = 0): never {
|
||||
const cookiePath = resolveGeminiWebCookiePath();
|
||||
const profileDir = resolveGeminiWebChromeProfileDir();
|
||||
type CliArgs = {
|
||||
prompt: string | null;
|
||||
promptFiles: string[];
|
||||
modelId: string;
|
||||
json: boolean;
|
||||
imagePath: string | null;
|
||||
referenceImages: string[];
|
||||
sessionId: string | null;
|
||||
listSessions: boolean;
|
||||
login: boolean;
|
||||
cookiePath: string | null;
|
||||
profileDir: string | null;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
type SessionRecord = {
|
||||
id: string;
|
||||
metadata: Array<string | null>;
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string; timestamp: string; error?: string }>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type LegacySessionV1 = {
|
||||
version?: number;
|
||||
sessionId?: string;
|
||||
updatedAt?: string;
|
||||
conversationId?: string | null;
|
||||
responseId?: string | null;
|
||||
choiceId?: string | null;
|
||||
chatMetadata?: unknown;
|
||||
};
|
||||
|
||||
function normalizeSessionMetadata(input: unknown): Array<string | null> {
|
||||
if (Array.isArray(input)) {
|
||||
const out: Array<string | null> = [];
|
||||
for (const v of input.slice(0, 3)) out.push(typeof v === 'string' ? v : null);
|
||||
return out.length > 0 ? out : [null, null, null];
|
||||
}
|
||||
|
||||
if (input && typeof input === 'object') {
|
||||
const v1 = input as LegacySessionV1;
|
||||
if (Array.isArray(v1.chatMetadata)) return normalizeSessionMetadata(v1.chatMetadata);
|
||||
|
||||
const conv = typeof v1.conversationId === 'string' ? v1.conversationId : null;
|
||||
const rid = typeof v1.responseId === 'string' ? v1.responseId : null;
|
||||
const rcid = typeof v1.choiceId === 'string' ? v1.choiceId : null;
|
||||
if (conv || rid || rcid) return [conv, rid, rcid];
|
||||
}
|
||||
|
||||
return [null, null, null];
|
||||
}
|
||||
|
||||
function printUsage(cookiePath: string, profileDir: string): void {
|
||||
console.log(`Usage:
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "Hello"
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Hello"
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
Multi-turn conversation (agent generates unique sessionId):
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Remember 42" --sessionId abc123
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "What number?" --sessionId abc123
|
||||
|
||||
Options:
|
||||
-p, --prompt <text> Prompt text
|
||||
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
|
||||
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
|
||||
--json Output JSON
|
||||
--image [path] Generate an image and save it (default: ./generated.png)
|
||||
--reference <files...> Reference images for vision input
|
||||
--ref <files...> Alias for --reference
|
||||
--sessionId <id> Session ID for multi-turn conversation (agent should generate unique ID)
|
||||
--list-sessions List saved sessions (max 100, sorted by update time)
|
||||
--login Only refresh cookies, then exit
|
||||
--cookie-path <path> Cookie file path (default: ${cookiePath})
|
||||
--profile-dir <path> Chrome profile dir (default: ${profileDir})
|
||||
-h, --help Show help
|
||||
|
||||
Env overrides:
|
||||
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH
|
||||
`);
|
||||
|
||||
process.exit(exitCode);
|
||||
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH`);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
modelId: 'gemini-3-pro',
|
||||
json: false,
|
||||
imagePath: null,
|
||||
referenceImages: [],
|
||||
sessionId: null,
|
||||
listSessions: false,
|
||||
login: false,
|
||||
cookiePath: null,
|
||||
profileDir: null,
|
||||
help: false,
|
||||
};
|
||||
|
||||
async function readPromptFromStdin(): Promise<string | null> {
|
||||
if (process.stdin.isTTY) return null;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8').trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
function readPromptFiles(filePaths: string[]): string {
|
||||
const contents: string[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new Error(`Prompt file not found: ${resolved}`);
|
||||
}
|
||||
const content = fs.readFileSync(resolved, 'utf8').trim();
|
||||
contents.push(content);
|
||||
}
|
||||
return contents.join('\n\n');
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
prompt?: string;
|
||||
promptFiles?: string[];
|
||||
model?: string;
|
||||
json?: boolean;
|
||||
imagePath?: string;
|
||||
loginOnly?: boolean;
|
||||
cookiePath?: string;
|
||||
profileDir?: string;
|
||||
} {
|
||||
const out: ReturnType<typeof parseArgs> = {};
|
||||
const positional: string[] = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i] ?? '';
|
||||
if (arg === '--help' || arg === '-h') printUsage(0);
|
||||
if (arg === '--json') {
|
||||
const takeMany = (i: number): { items: string[]; next: number } => {
|
||||
const items: string[] = [];
|
||||
let j = i + 1;
|
||||
while (j < argv.length) {
|
||||
const v = argv[j]!;
|
||||
if (v.startsWith('-')) break;
|
||||
items.push(v);
|
||||
j++;
|
||||
}
|
||||
return { items, next: j - 1 };
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
|
||||
if (a === '--help' || a === '-h') {
|
||||
out.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--json') {
|
||||
out.json = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--image' || arg === '--generate-image') {
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith('-')) {
|
||||
out.imagePath = next;
|
||||
i += 1;
|
||||
|
||||
if (a === '--list-sessions') {
|
||||
out.listSessions = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--login') {
|
||||
out.login = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--prompt' || a === '-p') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error(`Missing value for ${a}`);
|
||||
out.prompt = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--promptfiles') {
|
||||
const { items, next } = takeMany(i);
|
||||
if (items.length === 0) throw new Error('Missing files for --promptfiles');
|
||||
out.promptFiles.push(...items);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--model' || a === '-m') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error(`Missing value for ${a}`);
|
||||
out.modelId = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--sessionId') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --sessionId');
|
||||
out.sessionId = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--cookie-path') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --cookie-path');
|
||||
out.cookiePath = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--profile-dir') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --profile-dir');
|
||||
out.profileDir = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--image' || a.startsWith('--image=')) {
|
||||
let v: string | null = null;
|
||||
if (a.startsWith('--image=')) {
|
||||
v = a.slice('--image='.length).trim();
|
||||
} else {
|
||||
out.imagePath = 'generated.png';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--image=')) {
|
||||
out.imagePath = arg.slice('--image='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--generate-image=')) {
|
||||
out.imagePath = arg.slice('--generate-image='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--login') {
|
||||
out.loginOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--prompt' || arg === '-p') {
|
||||
out.prompt = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--prompt=')) {
|
||||
out.prompt = arg.slice('--prompt='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--promptfiles') {
|
||||
out.promptFiles = [];
|
||||
while (i + 1 < argv.length) {
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith('-')) {
|
||||
out.promptFiles.push(next);
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
const maybe = argv[i + 1];
|
||||
if (maybe && !maybe.startsWith('-')) {
|
||||
v = maybe;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg === '--model' || arg === '-m') {
|
||||
out.model = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--model=')) {
|
||||
out.model = arg.slice('--model='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--cookie-path') {
|
||||
out.cookiePath = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--cookie-path=')) {
|
||||
out.cookiePath = arg.slice('--cookie-path='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--profile-dir') {
|
||||
out.profileDir = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--profile-dir=')) {
|
||||
out.profileDir = arg.slice('--profile-dir='.length);
|
||||
|
||||
out.imagePath = v && v.length > 0 ? v : 'generated.png';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
if (a === '--reference' || a === '--ref') {
|
||||
const { items, next } = takeMany(i);
|
||||
if (items.length === 0) throw new Error(`Missing files for ${a}`);
|
||||
out.referenceImages.push(...items);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
positional.push(arg);
|
||||
|
||||
if (a.startsWith('-')) {
|
||||
throw new Error(`Unknown option: ${a}`);
|
||||
}
|
||||
|
||||
positional.push(a);
|
||||
}
|
||||
|
||||
if (!out.prompt && positional.length > 0) {
|
||||
out.prompt = positional.join(' ').trim();
|
||||
if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) {
|
||||
out.prompt = positional.join(' ');
|
||||
}
|
||||
|
||||
if (out.prompt != null) out.prompt = out.prompt.trim();
|
||||
if (out.model != null) out.model = out.model.trim();
|
||||
if (out.imagePath != null) out.imagePath = out.imagePath.trim();
|
||||
if (out.cookiePath != null) out.cookiePath = out.cookiePath.trim();
|
||||
if (out.profileDir != null) out.profileDir = out.profileDir.trim();
|
||||
|
||||
if (out.imagePath === '') delete out.imagePath;
|
||||
if (out.cookiePath === '') delete out.cookiePath;
|
||||
if (out.profileDir === '') delete out.profileDir;
|
||||
if (out.promptFiles?.length === 0) delete out.promptFiles;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function isCookieMapValid(cookieMap: Record<string, string>): Promise<boolean> {
|
||||
if (!hasRequiredGeminiCookies(cookieMap)) return false;
|
||||
function resolveModel(id: string): Model {
|
||||
const k = id.trim();
|
||||
if (k === 'gemini-3-pro') return Model.G_3_0_PRO;
|
||||
if (k === 'gemini-3.0-pro') return Model.G_3_0_PRO;
|
||||
if (k === 'gemini-2.5-pro') return Model.G_2_5_PRO;
|
||||
if (k === 'gemini-2.5-flash') return Model.G_2_5_FLASH;
|
||||
return Model.from_name(k);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
async function readPromptFromFiles(files: string[]): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
for (const f of files) {
|
||||
parts.push(await readFile(f, 'utf8'));
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
async function readPromptFromStdin(): Promise<string | null> {
|
||||
if (process.stdin.isTTY) return null;
|
||||
try {
|
||||
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
||||
return true;
|
||||
// Bun provides Bun.stdin; Node-compatible read can be flaky across runtimes.
|
||||
const t = await Bun.stdin.text();
|
||||
const v = t.trim();
|
||||
return v.length > 0 ? v : null;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGeminiCookieMap(options: {
|
||||
cookiePath: string;
|
||||
profileDir: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
const log = (msg: string) => console.log(msg);
|
||||
|
||||
let cookieMap = await readGeminiCookieMapFromDisk({ cookiePath: options.cookiePath, log });
|
||||
if (await isCookieMapValid(cookieMap)) return cookieMap;
|
||||
|
||||
log('[gemini-web] No valid cookies found. Opening browser to sync Gemini cookies...');
|
||||
cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: options.profileDir, log });
|
||||
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath: options.cookiePath, log });
|
||||
return cookieMap;
|
||||
function normalizeOutputImagePath(p: string): string {
|
||||
const full = path.resolve(p);
|
||||
const ext = path.extname(full);
|
||||
if (ext) return full;
|
||||
return `${full}.png`;
|
||||
}
|
||||
|
||||
function resolveModel(value: string): 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash' {
|
||||
const desired = value.trim();
|
||||
if (!desired) return 'gemini-3-pro';
|
||||
switch (desired) {
|
||||
case 'gemini-3-pro':
|
||||
case 'gemini-3.0-pro':
|
||||
return 'gemini-3-pro';
|
||||
case 'gemini-2.5-pro':
|
||||
return 'gemini-2.5-pro';
|
||||
case 'gemini-2.5-flash':
|
||||
return 'gemini-2.5-flash';
|
||||
default:
|
||||
console.error(`[gemini-web] Unsupported model "${desired}", falling back to gemini-3-pro.`);
|
||||
return 'gemini-3-pro';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImageOutputPath(value: string | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
const raw = trimmed || 'generated.png';
|
||||
const resolved = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
|
||||
|
||||
if (resolved.endsWith(path.sep)) return path.join(resolved, 'generated.png');
|
||||
async function loadSession(id: string): Promise<SessionRecord | null> {
|
||||
const p = resolveGeminiWebSessionPath(id);
|
||||
try {
|
||||
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
||||
return path.join(resolved, 'generated.png');
|
||||
const raw = await readFile(p, 'utf8');
|
||||
const j = JSON.parse(raw) as unknown;
|
||||
if (!j || typeof j !== 'object') return null;
|
||||
|
||||
const sid = (typeof (j as any).id === 'string' && (j as any).id.trim()) || (typeof (j as any).sessionId === 'string' && (j as any).sessionId.trim()) || id;
|
||||
const metadata = normalizeSessionMetadata((j as any).metadata ?? (j as any).chatMetadata ?? j);
|
||||
const messages = Array.isArray((j as any).messages) ? ((j as any).messages as SessionRecord['messages']) : [];
|
||||
const createdAt =
|
||||
typeof (j as any).createdAt === 'string'
|
||||
? ((j as any).createdAt as string)
|
||||
: typeof (j as any).updatedAt === 'string'
|
||||
? ((j as any).updatedAt as string)
|
||||
: new Date().toISOString();
|
||||
const updatedAt = typeof (j as any).updatedAt === 'string' ? ((j as any).updatedAt as string) : createdAt;
|
||||
|
||||
return {
|
||||
id: sid,
|
||||
metadata,
|
||||
messages,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSession(rec: SessionRecord): Promise<void> {
|
||||
const dir = resolveGeminiWebSessionsDir();
|
||||
await mkdir(dir, { recursive: true });
|
||||
const p = resolveGeminiWebSessionPath(rec.id);
|
||||
const tmp = `${p}.tmp.${Date.now()}`;
|
||||
await writeFile(tmp, JSON.stringify(rec, null, 2), 'utf8');
|
||||
await fs.promises.rename(tmp, p);
|
||||
}
|
||||
|
||||
async function listSessions(): Promise<SessionRecord[]> {
|
||||
const dir = resolveGeminiWebSessionsDir();
|
||||
try {
|
||||
const names = await readdir(dir);
|
||||
const items: Array<{ path: string; st: number }> = [];
|
||||
for (const n of names) {
|
||||
if (!n.endsWith('.json')) continue;
|
||||
const p = path.join(dir, n);
|
||||
try {
|
||||
const s = await stat(p);
|
||||
items.push({ path: p, st: s.mtimeMs });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.st - a.st);
|
||||
const out: SessionRecord[] = [];
|
||||
for (const it of items.slice(0, 100)) {
|
||||
try {
|
||||
const raw = await readFile(it.path, 'utf8');
|
||||
const j = JSON.parse(raw) as any;
|
||||
const id =
|
||||
(typeof j?.id === 'string' && j.id.trim()) ||
|
||||
(typeof j?.sessionId === 'string' && j.sessionId.trim()) ||
|
||||
path.basename(it.path, '.json');
|
||||
out.push({
|
||||
id,
|
||||
metadata: normalizeSessionMetadata(j?.metadata ?? j?.chatMetadata ?? j),
|
||||
messages: Array.isArray(j?.messages) ? j.messages : [],
|
||||
createdAt:
|
||||
typeof j?.createdAt === 'string'
|
||||
? j.createdAt
|
||||
: typeof j?.updatedAt === 'string'
|
||||
? j.updatedAt
|
||||
: new Date(it.st).toISOString(),
|
||||
updatedAt: typeof j?.updatedAt === 'string' ? j.updatedAt : new Date(it.st).toISOString(),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
out.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
||||
return out.slice(0, 100);
|
||||
} catch {
|
||||
// ignore
|
||||
return [];
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function formatJson(out: ModelOutput, extra?: Record<string, unknown>): string {
|
||||
const candidates = out.candidates.map((c) => ({
|
||||
rcid: c.rcid,
|
||||
text: c.text,
|
||||
thoughts: c.thoughts,
|
||||
images: c.images.map((img) => ({
|
||||
url: img.url,
|
||||
title: img.title,
|
||||
alt: img.alt,
|
||||
kind: img instanceof GeneratedImage ? 'generated' : 'web',
|
||||
})),
|
||||
}));
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
text: out.text,
|
||||
thoughts: out.thoughts,
|
||||
metadata: out.metadata,
|
||||
chosen: out.chosen,
|
||||
candidates,
|
||||
...extra,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cookiePath = args.cookiePath ?? resolveGeminiWebCookiePath();
|
||||
const profileDir = args.profileDir ?? resolveGeminiWebChromeProfileDir();
|
||||
|
||||
if (args.loginOnly) {
|
||||
await ensureGeminiCookieMap({ cookiePath, profileDir });
|
||||
if (args.cookiePath) process.env.GEMINI_WEB_COOKIE_PATH = args.cookiePath;
|
||||
if (args.profileDir) process.env.GEMINI_WEB_CHROME_PROFILE_DIR = args.profileDir;
|
||||
|
||||
const cookiePath = resolveGeminiWebCookiePath();
|
||||
const profileDir = resolveGeminiWebChromeProfileDir();
|
||||
|
||||
if (args.help) {
|
||||
printUsage(cookiePath, profileDir);
|
||||
return;
|
||||
}
|
||||
|
||||
const promptFromStdin = await readPromptFromStdin();
|
||||
const promptFromFiles = args.promptFiles ? readPromptFiles(args.promptFiles) : null;
|
||||
const prompt = promptFromFiles || args.prompt || promptFromStdin;
|
||||
if (!prompt) printUsage(1);
|
||||
if (args.listSessions) {
|
||||
const ss = await listSessions();
|
||||
for (const s of ss) {
|
||||
const n = s.messages.length;
|
||||
const last = s.messages.slice(-1)[0];
|
||||
const lastLine = last?.content ? String(last.content).split('\n')[0] : '';
|
||||
console.log(`${s.id}\t${s.updatedAt}\t${n}\t${lastLine}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir });
|
||||
if (args.login) {
|
||||
process.env.GEMINI_WEB_LOGIN = '1';
|
||||
const c = new GeminiClient();
|
||||
await c.init({ verbose: true });
|
||||
await c.close();
|
||||
if (!args.json) console.log(`Cookie refreshed: ${cookiePath}`);
|
||||
else console.log(JSON.stringify({ ok: true, cookiePath }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredModel = resolveModel(args.model || 'gemini-3-pro');
|
||||
const imagePath = resolveImageOutputPath(args.imagePath);
|
||||
let prompt: string | null = args.prompt;
|
||||
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
|
||||
if (!prompt) prompt = await readPromptFromStdin();
|
||||
|
||||
if (!prompt) {
|
||||
printUsage(cookiePath, profileDir);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const model = resolveModel(args.modelId);
|
||||
|
||||
const c = new GeminiClient();
|
||||
await c.init({ verbose: false });
|
||||
try {
|
||||
const effectivePrompt = imagePath ? `Generate an image: ${prompt}` : prompt;
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt: effectivePrompt,
|
||||
files: [],
|
||||
model: desiredModel,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
});
|
||||
let sess: SessionRecord | null = null;
|
||||
let chat = null as any;
|
||||
|
||||
let imageSaved = false;
|
||||
let imageCount = 0;
|
||||
if (imagePath) {
|
||||
const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath);
|
||||
imageSaved = save.saved;
|
||||
imageCount = save.imageCount;
|
||||
if (!imageSaved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
if (args.sessionId) {
|
||||
sess = (await loadSession(args.sessionId)) ?? {
|
||||
id: args.sessionId,
|
||||
metadata: [null, null, null],
|
||||
messages: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
chat = c.start_chat({ metadata: sess.metadata, model });
|
||||
}
|
||||
|
||||
const files = args.referenceImages.length > 0 ? args.referenceImages : null;
|
||||
|
||||
let out: ModelOutput;
|
||||
if (chat) out = await chat.send_message(prompt, files);
|
||||
else out = await c.generate_content(prompt, files, model);
|
||||
|
||||
let savedImage: string | null = null;
|
||||
if (args.imagePath) {
|
||||
const p = normalizeOutputImagePath(args.imagePath);
|
||||
const dir = path.dirname(p);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const img = out.images[0];
|
||||
if (!img) {
|
||||
throw new Error('No image returned in response.');
|
||||
}
|
||||
|
||||
const fn = path.basename(p);
|
||||
const dp = dir;
|
||||
|
||||
if (img instanceof GeneratedImage) {
|
||||
savedImage = await img.save(dp, fn, undefined, false, false, true);
|
||||
} else {
|
||||
savedImage = await img.save(dp, fn, c.cookies, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (sess && args.sessionId) {
|
||||
const now = new Date().toISOString();
|
||||
sess.updatedAt = now;
|
||||
sess.metadata = (chat?.metadata ?? sess.metadata).slice(0, 3);
|
||||
sess.messages.push({ role: 'user', content: prompt, timestamp: now });
|
||||
sess.messages.push({ role: 'assistant', content: out.text ?? '', timestamp: now });
|
||||
await saveSession(sess);
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
imagePath ? { ...out, imageSaved, imageCount, imagePath } : out,
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
if (out.errorMessage) process.exit(1);
|
||||
return;
|
||||
console.log(formatJson(out, { savedImage, sessionId: args.sessionId, model: model.model_name }));
|
||||
} else if (args.imagePath) {
|
||||
console.log(savedImage ?? '');
|
||||
} else {
|
||||
console.log(out.text);
|
||||
}
|
||||
|
||||
if (out.errorMessage) {
|
||||
throw new Error(out.errorMessage);
|
||||
}
|
||||
|
||||
process.stdout.write(out.text ?? '');
|
||||
if (!out.text?.endsWith('\n')) process.stdout.write('\n');
|
||||
if (imagePath) {
|
||||
process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (message.includes('Unable to locate Gemini access token')) {
|
||||
console.error('[gemini-web] Cookies may be expired. Re-opening browser to refresh cookies...');
|
||||
await sleep(500);
|
||||
cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log: (m) => console.log(m) });
|
||||
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log: (m) => console.log(m) });
|
||||
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt: imagePath ? `Generate an image: ${prompt}` : prompt,
|
||||
files: [],
|
||||
model: desiredModel,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
});
|
||||
|
||||
let imageSaved = false;
|
||||
let imageCount = 0;
|
||||
if (imagePath) {
|
||||
const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath);
|
||||
imageSaved = save.saved;
|
||||
imageCount = save.imageCount;
|
||||
if (!imageSaved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
imagePath ? { ...out, imageSaved, imageCount, imagePath } : out,
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
if (out.errorMessage) process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (out.errorMessage) {
|
||||
throw new Error(out.errorMessage);
|
||||
}
|
||||
|
||||
process.stdout.write(out.text ?? '');
|
||||
if (!out.text?.endsWith('\n')) process.stdout.write('\n');
|
||||
if (imagePath) {
|
||||
process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
await c.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
main().catch((e) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export interface GeminiWebOptions {
|
||||
youtube?: string;
|
||||
generateImage?: string;
|
||||
editImage?: string;
|
||||
outputPath?: string;
|
||||
showThoughts?: boolean;
|
||||
aspectRatio?: string;
|
||||
}
|
||||
|
||||
export interface GeminiWebResponse {
|
||||
text: string | null;
|
||||
thoughts: string | null;
|
||||
has_images: boolean;
|
||||
image_count: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -481,52 +481,78 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
const img = sortedImages[i]!;
|
||||
console.log(`[x-article] [${i + 1}/${sortedImages.length}] Inserting image at placeholder: ${img.placeholder}`);
|
||||
|
||||
// Find, scroll to, and select the placeholder text in DraftEditor
|
||||
const placeholderFound = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
|
||||
if (!editor) return false;
|
||||
// Helper to select placeholder with retry
|
||||
const selectPlaceholder = async (maxRetries = 3): Promise<boolean> => {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
// Find, scroll to, and select the placeholder text in DraftEditor
|
||||
await cdp!.send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
|
||||
if (!editor) return false;
|
||||
|
||||
const placeholder = ${JSON.stringify(img.placeholder)};
|
||||
const placeholder = ${JSON.stringify(img.placeholder)};
|
||||
|
||||
// Search through all text nodes in the editor
|
||||
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
// Search through all text nodes in the editor
|
||||
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || '';
|
||||
const idx = text.indexOf(placeholder);
|
||||
if (idx !== -1) {
|
||||
// Found the placeholder - scroll to it first
|
||||
const parentElement = node.parentElement;
|
||||
if (parentElement) {
|
||||
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || '';
|
||||
const idx = text.indexOf(placeholder);
|
||||
if (idx !== -1) {
|
||||
// Found the placeholder - scroll to it first
|
||||
const parentElement = node.parentElement;
|
||||
if (parentElement) {
|
||||
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Select it
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + placeholder.length);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})()`,
|
||||
}, { sessionId });
|
||||
|
||||
// Select it
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + placeholder.length);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
}
|
||||
// Wait for scroll and selection to settle
|
||||
await sleep(800);
|
||||
|
||||
// Verify selection matches the placeholder
|
||||
const selectionCheck = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `window.getSelection()?.toString() || ''`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
const selectedText = selectionCheck.result.value.trim();
|
||||
if (selectedText === img.placeholder) {
|
||||
console.log(`[x-article] Selection verified: "${selectedText}"`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
// Wait for scroll animation
|
||||
await sleep(500);
|
||||
if (attempt < maxRetries) {
|
||||
console.log(`[x-article] Selection attempt ${attempt} got "${selectedText}", retrying...`);
|
||||
await sleep(500);
|
||||
} else {
|
||||
console.warn(`[x-article] Selection failed after ${maxRetries} attempts, got: "${selectedText}"`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!placeholderFound.result.value) {
|
||||
console.warn(`[x-article] Placeholder not found in DOM: ${img.placeholder}`);
|
||||
// Try to select the placeholder
|
||||
const selected = await selectPlaceholder(3);
|
||||
if (!selected) {
|
||||
console.warn(`[x-article] Skipping image - could not select placeholder: ${img.placeholder}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[x-article] Placeholder selected, copying image: ${path.basename(img.localPath)}`);
|
||||
console.log(`[x-article] Copying image: ${path.basename(img.localPath)}`);
|
||||
|
||||
// Copy image to clipboard
|
||||
if (!copyImageToClipboard(img.localPath)) {
|
||||
@@ -535,17 +561,48 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
|
||||
// Wait for clipboard to be fully ready
|
||||
await sleep(800);
|
||||
await sleep(1000);
|
||||
|
||||
// Delete placeholder by pressing Enter (placeholder is already selected)
|
||||
console.log(`[x-article] Deleting placeholder with Enter...`);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
|
||||
// Delete placeholder by pressing Backspace (more reliable than Enter for replacing selection)
|
||||
console.log(`[x-article] Deleting placeholder...`);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
|
||||
// Wait and verify placeholder is deleted
|
||||
await sleep(500);
|
||||
|
||||
// Check that placeholder is no longer in editor
|
||||
const afterDelete = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
|
||||
if (!editor) return true;
|
||||
return !editor.innerText.includes(${JSON.stringify(img.placeholder)});
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
if (!afterDelete.result.value) {
|
||||
console.warn(`[x-article] Placeholder may not have been deleted, trying again...`);
|
||||
// Try selecting and deleting again
|
||||
await selectPlaceholder(1);
|
||||
await sleep(300);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Focus editor to ensure cursor is in position
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
|
||||
if (editor) editor.focus();
|
||||
})()`,
|
||||
}, { sessionId });
|
||||
await sleep(300);
|
||||
|
||||
// Paste image using paste script (activates Chrome, sends real keystroke)
|
||||
console.log(`[x-article] Pasting image...`);
|
||||
if (pasteFromClipboard('Google Chrome', 5, 800)) {
|
||||
if (pasteFromClipboard('Google Chrome', 5, 1000)) {
|
||||
console.log(`[x-article] Image pasted: ${path.basename(img.localPath)}`);
|
||||
} else {
|
||||
console.warn(`[x-article] Failed to paste image after retries`);
|
||||
|
||||
+140
-363
@@ -5,435 +5,212 @@ description: Generate professional slide deck images from content. Creates compr
|
||||
|
||||
# Slide Deck Generator
|
||||
|
||||
Transform content into professional slide deck with comprehensive outlines and generated slide images.
|
||||
Transform content into professional slide deck images with flexible style options.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# From markdown file
|
||||
/baoyu-slide-deck path/to/article.md
|
||||
|
||||
# With style preference
|
||||
/baoyu-slide-deck path/to/article.md --style corporate
|
||||
/baoyu-slide-deck path/to/article.md --style playful
|
||||
/baoyu-slide-deck path/to/article.md --style technical
|
||||
|
||||
# With audience specification
|
||||
/baoyu-slide-deck path/to/article.md --audience beginners
|
||||
/baoyu-slide-deck path/to/article.md --audience executives
|
||||
|
||||
# With language
|
||||
/baoyu-slide-deck path/to/article.md --lang zh
|
||||
/baoyu-slide-deck path/to/article.md --lang en
|
||||
|
||||
# Outline only (no image generation)
|
||||
/baoyu-slide-deck path/to/article.md --outline-only
|
||||
|
||||
# Direct content input
|
||||
/baoyu-slide-deck
|
||||
[paste content]
|
||||
/baoyu-slide-deck path/to/content.md
|
||||
/baoyu-slide-deck path/to/content.md --style sketch-notes
|
||||
/baoyu-slide-deck path/to/content.md --audience executives
|
||||
/baoyu-slide-deck path/to/content.md --lang zh
|
||||
/baoyu-slide-deck path/to/content.md --slides 10
|
||||
/baoyu-slide-deck path/to/content.md --outline-only
|
||||
/baoyu-slide-deck # Then paste content
|
||||
```
|
||||
|
||||
## Script Directory
|
||||
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/merge-to-pptx.ts` | Merge slides into PowerPoint |
|
||||
| `scripts/merge-to-pdf.ts` | Merge slides into PDF |
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--style <name>` | Visual style preset (see Style Gallery) |
|
||||
| `--audience <type>` | Target audience level |
|
||||
| `--lang <code>` | Output language (en, zh, etc.) |
|
||||
| `--slides <number>` | Target slide count (max 20) |
|
||||
| `--style <name>` | Visual style (see Style Gallery) |
|
||||
| `--audience <type>` | Target audience: beginners, intermediate, experts, executives, general |
|
||||
| `--lang <code>` | Output language (en, zh, ja, etc.) |
|
||||
| `--slides <number>` | Target slide count |
|
||||
| `--outline-only` | Generate outline only, skip image generation |
|
||||
|
||||
## Style Gallery
|
||||
|
||||
### 1. `editorial` (Default)
|
||||
Clean, sophisticated, minimalist
|
||||
- **Aesthetic**: High-end journal, architectural blueprints
|
||||
- **Colors**: Off-white background (#F8F7F5), dark slate text (#2F3542), blue accents (#007AFF)
|
||||
- **Best for**: Thought leadership, research presentations
|
||||
| Style | Description | Best For |
|
||||
|-------|-------------|----------|
|
||||
| `sketch-notes` | Hand-drawn, warm & friendly | Educational, tutorials |
|
||||
| `blueprint` | Technical, precise & analytical | Architecture, system design |
|
||||
| `bold-editorial` | Magazine, high-impact & dynamic | Product launches, keynotes |
|
||||
| `vector-illustration` | Flat vector, retro & cute | Creative, children's content |
|
||||
| `minimal` | Ultra-clean, maximum whitespace | Executive briefings, premium |
|
||||
| `storytelling` | Cinematic, full-bleed visuals | Narratives, case studies |
|
||||
| `warm` | Soft gradients, wellness aesthetic | Lifestyle, personal development |
|
||||
| `notion` (Default) | SaaS dashboard, clean data focus | Product demos, productivity |
|
||||
| `corporate` | Navy/gold, professional | Investor decks, proposals |
|
||||
| `playful` | Vibrant, dynamic shapes | Workshops, training |
|
||||
|
||||
### 2. `corporate`
|
||||
Professional, trustworthy, polished
|
||||
- **Aesthetic**: Clean lines, structured layouts, business-appropriate
|
||||
- **Colors**: Navy (#1E3A5F), white, gold accents (#C9A227)
|
||||
- **Best for**: Business presentations, investor decks, reports
|
||||
## Auto Style Selection
|
||||
|
||||
### 3. `technical`
|
||||
Precise, data-driven, analytical
|
||||
- **Aesthetic**: Blueprint style, schematic diagrams, grid layouts
|
||||
- **Colors**: Dark charcoal (#1A1A2E), cyan accents (#00D4FF), light gray text (#E8E8E8)
|
||||
- **Best for**: Technical documentation, engineering presentations
|
||||
| Content Signals | Selected Style |
|
||||
|-----------------|----------------|
|
||||
| tutorial, learn, education, guide, intro, beginner | `sketch-notes` |
|
||||
| architecture, system, data, analysis, technical | `blueprint` |
|
||||
| launch, marketing, brand, keynote, impact | `bold-editorial` |
|
||||
| creative, children, kids, cute, illustration | `vector-illustration` |
|
||||
| executive, minimal, clean, simple, elegant | `minimal` |
|
||||
| story, journey, case study, narrative, emotional | `storytelling` |
|
||||
| wellness, lifestyle, personal, growth, mindfulness | `warm` |
|
||||
| saas, product, dashboard, metrics, productivity | `notion` |
|
||||
| investor, quarterly, business, corporate, proposal | `corporate` |
|
||||
| workshop, training, fun, playful, energetic | `playful` |
|
||||
| Default | `notion` |
|
||||
|
||||
### 4. `playful`
|
||||
Bold, energetic, engaging
|
||||
- **Aesthetic**: Dynamic shapes, vibrant colors, creative layouts
|
||||
- **Colors**: Warm white (#FFFDF7), deep purple (#4A1D96), coral (#FF6B6B)
|
||||
- **Best for**: Creative pitches, educational content, workshops
|
||||
## Design Philosophy
|
||||
|
||||
### 5. `minimal`
|
||||
Ultra-clean, zen-like, focused
|
||||
- **Aesthetic**: Maximum whitespace, single focal points, elegant typography
|
||||
- **Colors**: Black, white, single accent color
|
||||
- **Best for**: Keynotes, philosophical content, product launches
|
||||
|
||||
### 6. `storytelling`
|
||||
Narrative-driven, cinematic, immersive
|
||||
- **Aesthetic**: Full-bleed imagery, dramatic typography, chapter-like flow
|
||||
- **Colors**: Rich charcoal (#2D2D2D), warm white (#FAF9F6), gold (#D4AF37)
|
||||
- **Best for**: Case studies, journey narratives, brand stories
|
||||
|
||||
### 7. `warm`
|
||||
Cozy, healing, hand-drawn illustration style
|
||||
- **Aesthetic**: Soft hand-drawn illustrations, cozy and healing atmosphere, gentle curves, watercolor textures
|
||||
- **Colors**: Cream gradient background (#FFF8E7 → #FFE4C4), warm brown text (#5D4037), soft coral accents (#FF8A80)
|
||||
- **Best for**: Personal growth, wellness, emotional content, lifestyle topics
|
||||
|
||||
### 8. `retro-flat`
|
||||
Flat vector illustration with retro palette
|
||||
- **Aesthetic**: Flat vector with clear black outlines (monoline), geometric simplification, toy-model cuteness, NO gradients
|
||||
- **Colors**: Retro muted palette - dusty pink (#E8B4B8), sage green (#A8C686), mustard yellow (#E8B84A), off-white background (#F5F0E6)
|
||||
- **Best for**: Tutorials, explainers, product introductions, educational content
|
||||
|
||||
### 9. `notion`
|
||||
Minimalist hand-drawn line art, intellectual
|
||||
- **Aesthetic**: Simple line doodles with hand-drawn wobble, geometric shapes, maximum whitespace, SaaS product feel
|
||||
- **Colors**: Black outlines (#1A1A1A), white background (#FFFFFF), 1-2 pastel accents (blue #A8D4F0, yellow #F9E79F)
|
||||
- **Best for**: Knowledge sharing, concept explanations, productivity content, SaaS presentations
|
||||
|
||||
## Audience Presets
|
||||
|
||||
| Audience | Approach |
|
||||
|----------|----------|
|
||||
| `beginners` | Step-by-step, more context, simpler visuals |
|
||||
| `intermediate` | Balanced detail, some assumed knowledge |
|
||||
| `experts` | Dense information, technical depth, less hand-holding |
|
||||
| `executives` | High-level insights, key metrics, strategic focus |
|
||||
| `general` | Accessible language, broad appeal, clear takeaways |
|
||||
This deck is designed for **reading and sharing**, not live presentation:
|
||||
- Each slide must be **self-explanatory** without verbal commentary
|
||||
- Structure content for **logical flow** when scrolling
|
||||
- Include **all necessary context** within each slide
|
||||
- Optimize for **social media sharing** and offline reading
|
||||
|
||||
## File Management
|
||||
|
||||
### With Article Path
|
||||
|
||||
### With Content Path
|
||||
```
|
||||
path/to/
|
||||
├── article.md
|
||||
└── slide-deck/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── 01-cover.md
|
||||
│ ├── 02-content-1.md
|
||||
│ └── ...
|
||||
├── 01-cover.png
|
||||
├── 02-content-1.png
|
||||
└── ...
|
||||
content-dir/
|
||||
├── source-content.md
|
||||
└── source-content/
|
||||
└── slide-deck/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ └── 01-slide-cover.md, 02-slide-{slug}.md, ...
|
||||
├── 01-slide-cover.png, 02-slide-{slug}.png, ...
|
||||
├── {topic-slug}.pptx
|
||||
└── {topic-slug}.pdf
|
||||
```
|
||||
|
||||
### Without Article Path
|
||||
Example: `/posts/ai-intro.md` → `/posts/ai-intro/slide-deck/`
|
||||
|
||||
### Without Content Path (Pasted Content)
|
||||
```
|
||||
./baoyu-slide-deck-outputs/YYYY-MM-DD/[topic-slug]/
|
||||
slide-deck/{topic-slug}/
|
||||
├── source.md
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── 01-cover.md
|
||||
│ └── ...
|
||||
├── 01-cover.png
|
||||
└── ...
|
||||
├── *.png
|
||||
├── {topic-slug}.pptx
|
||||
└── {topic-slug}.pdf
|
||||
```
|
||||
|
||||
### Directory Backup
|
||||
|
||||
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content & Select Style
|
||||
### Step 1: Analyze Content
|
||||
|
||||
1. Read source content
|
||||
2. If `--style` specified, use that style
|
||||
3. Otherwise, analyze content for style signals:
|
||||
1. Save source content (if pasted, save as `source.md`)
|
||||
2. Follow `references/analysis-framework.md` for deep content analysis
|
||||
3. Determine style (use `--style` or auto-select from signals)
|
||||
4. Detect languages (source vs. user preference)
|
||||
5. Plan slide count (`--slides` or dynamic)
|
||||
|
||||
| Content Signals | Selected Style |
|
||||
|----------------|----------------|
|
||||
| AI, coding, tech, digital, algorithm, data | `technical` |
|
||||
| Business, strategy, investment, corporate | `corporate` |
|
||||
| Personal story, journey, narrative, emotion | `storytelling` |
|
||||
| Simple, zen, focus, essential, one idea | `minimal` |
|
||||
| Fun, creative, workshop, educational | `playful` |
|
||||
| Research, analysis, thought leadership | `editorial` |
|
||||
| Wellness, healing, cozy, self-care, lifestyle, comfort | `warm` |
|
||||
| Tutorial, explainer, how-to, beginner, product, guide | `retro-flat` |
|
||||
| Knowledge, concept, productivity, SaaS, notion, intellectual | `notion` |
|
||||
### Step 2: Generate Outline Variants
|
||||
|
||||
4. Extract key information:
|
||||
- Core narrative and key messages
|
||||
- Important data points and statistics
|
||||
- Logical flow and structure
|
||||
- Target audience signals
|
||||
1. Generate 3 style variant outlines based on content analysis
|
||||
2. Follow `references/outline-template.md` for structure
|
||||
3. Save as `outline-{style}.md` for each variant
|
||||
|
||||
### Step 2: Generate Outline
|
||||
### Step 3: User Confirmation
|
||||
|
||||
Create outline with `STYLE_INSTRUCTIONS` block and slide specifications.
|
||||
**Single AskUserQuestion with all applicable options:**
|
||||
|
||||
**Outline Format**:
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Style variant | Always (3 options + custom) |
|
||||
| Language | Only if source ≠ user language |
|
||||
|
||||
```markdown
|
||||
# Slide Deck Outline: [Topic]
|
||||
After selection:
|
||||
- Copy selected `outline-{style}.md` to `outline.md`
|
||||
- Regenerate in different language if requested
|
||||
- User may edit `outline.md` for fine-tuning
|
||||
|
||||
**Source**: [source file or "Direct input"]
|
||||
**Style**: [selected style]
|
||||
**Audience**: [target audience]
|
||||
**Language**: [output language]
|
||||
**Slide Count**: N slides
|
||||
**Generated**: YYYY-MM-DD HH:mm
|
||||
If `--outline-only`, stop here.
|
||||
|
||||
---
|
||||
### Step 4: Generate Prompts
|
||||
|
||||
<STYLE_INSTRUCTIONS>
|
||||
Design Aesthetic: [Overall style description]
|
||||
Background Color: [Description and Hex Code]
|
||||
Primary Font: [Font name for Headlines]
|
||||
Secondary Font: [Font name for Body copy]
|
||||
Color Palette:
|
||||
Primary Text Color: [Hex Code]
|
||||
Primary Accent Color: [Hex Code]
|
||||
Visual Elements: [Lines, shapes, imagery style, etc.]
|
||||
</STYLE_INSTRUCTIONS>
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: [Descriptive Title]
|
||||
|
||||
**Position**: Cover
|
||||
**Filename**: 01-cover.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[Storytelling purpose within the overall arc]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [Main message - narrative, not "Title: Subtitle" format]
|
||||
Sub-headline: [Supporting context]
|
||||
Body:
|
||||
- [Key point 1 with specific data from source]
|
||||
- [Key point 2 with specific data from source]
|
||||
|
||||
// VISUAL
|
||||
[Detailed description of imagery, charts, graphics, or abstract visuals]
|
||||
|
||||
// LAYOUT
|
||||
[Composition, hierarchy, spatial arrangement, focus points]
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: [First Content]
|
||||
...
|
||||
|
||||
## Slide N: [Back Cover]
|
||||
...
|
||||
```
|
||||
|
||||
**Required Slide Structure**:
|
||||
1. **Slide 1**: Cover Slide (poster-style, heroic typography)
|
||||
2. **Slides 2-N-1**: Content slides (consistent internal style)
|
||||
3. **Slide N**: Back Cover (closing statement, not "Thank You")
|
||||
|
||||
### Step 3: Save Outline
|
||||
|
||||
Save outline as `outline.md` in target directory.
|
||||
|
||||
If `--outline-only` flag is set, stop here.
|
||||
|
||||
### Step 4: Create Prompt Files
|
||||
|
||||
For each slide, create a style-specific prompt file.
|
||||
|
||||
**Prompt Format**:
|
||||
|
||||
```markdown
|
||||
Slide theme: [slide title]
|
||||
Style: [style name]
|
||||
Position: [cover/content/back-cover]
|
||||
|
||||
Visual composition:
|
||||
- Main visual: [style-appropriate description from VISUAL section]
|
||||
- Layout: [from LAYOUT section]
|
||||
- Decorative elements: [style-specific decorations]
|
||||
|
||||
Color scheme:
|
||||
- Background: [style background color]
|
||||
- Primary text: [style text color]
|
||||
- Accent: [style accent color]
|
||||
|
||||
Text content:
|
||||
- Headline: [headline text]
|
||||
- Sub-headline: [sub-headline if any]
|
||||
- Body points: [bullet points if any]
|
||||
|
||||
Style notes: [specific style characteristics to emphasize]
|
||||
```
|
||||
1. Read `references/base-prompt.md`
|
||||
2. Combine with style instructions from outline
|
||||
3. Add slide-specific content
|
||||
4. Save to `prompts/` directory
|
||||
|
||||
### Step 5: Generate Images
|
||||
|
||||
For each slide, generate using:
|
||||
1. Select available image generation skill
|
||||
2. Generate session ID: `slides-{topic-slug}-{timestamp}`
|
||||
3. Generate each slide with same session ID
|
||||
4. Report progress: "Generated X/N"
|
||||
|
||||
### Step 6: Merge to PPTX and PDF
|
||||
|
||||
```bash
|
||||
/baoyu-gemini-web --promptfiles [SKILL_ROOT]/skills/baoyu-slide-deck/prompts/system.md [TARGET_DIR]/prompts/01-cover.md --image [TARGET_DIR]/01-cover.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/merge-to-pptx.ts <slide-deck-dir>
|
||||
npx -y bun ${SKILL_DIR}/scripts/merge-to-pdf.ts <slide-deck-dir>
|
||||
```
|
||||
|
||||
Generation flow:
|
||||
1. Generate images sequentially
|
||||
2. After each image, output progress: "Generated X/N"
|
||||
3. On failure, auto-retry once
|
||||
4. If retry fails, log reason, continue to next
|
||||
|
||||
### Step 6: Completion Report
|
||||
### Step 7: Output Summary
|
||||
|
||||
```
|
||||
Slide Deck Generated!
|
||||
Slide Deck Complete!
|
||||
|
||||
Topic: [topic]
|
||||
Style: [style name]
|
||||
Audience: [audience]
|
||||
Location: [directory path]
|
||||
Slides: N total
|
||||
|
||||
- 01-cover.png ✓ Cover
|
||||
- 02-content-1.png ✓ Content
|
||||
- 03-content-2.png ✓ Content
|
||||
- 01-slide-cover.png ✓ Cover
|
||||
- 02-slide-intro.png ✓ Content
|
||||
- ...
|
||||
- 0N-back-cover.png ✓ Back Cover
|
||||
- {NN}-slide-back-cover.png ✓ Back Cover
|
||||
|
||||
Outline: outline.md
|
||||
|
||||
[If any failures]
|
||||
Failed:
|
||||
- 0X-slide-name.png: [failure reason]
|
||||
PPTX: {topic-slug}.pptx
|
||||
PDF: {topic-slug}.pdf
|
||||
```
|
||||
|
||||
## Style Reference Details
|
||||
## Slide Modification
|
||||
|
||||
### editorial
|
||||
```
|
||||
Design Aesthetic: Clean, sophisticated, minimalist editorial inspired by architectural blueprints and high-end technical journals
|
||||
Background Color: Subtle textured off-white #F8F7F5
|
||||
Primary Font: Neue Haas Grotesk Display Pro (bold headlines)
|
||||
Secondary Font: Tiempos Text (body copy, annotations)
|
||||
Primary Text Color: Dark slate grey #2F3542
|
||||
Primary Accent Color: Intelligent blue #007AFF
|
||||
Visual Elements: Thin precise line work, schematic diagrams, clean vectors, spacious layouts
|
||||
```
|
||||
See `references/modification-guide.md` for:
|
||||
- Edit single slide workflow
|
||||
- Add new slide (with renumbering)
|
||||
- Delete slide (with renumbering)
|
||||
- File naming conventions
|
||||
|
||||
### corporate
|
||||
```
|
||||
Design Aesthetic: Professional, trustworthy, structured business presentation
|
||||
Background Color: Clean white #FFFFFF with navy accents
|
||||
Primary Font: Inter (headlines)
|
||||
Secondary Font: Source Sans Pro (body)
|
||||
Primary Text Color: Navy #1E3A5F
|
||||
Primary Accent Color: Gold #C9A227
|
||||
Visual Elements: Clean charts, professional icons, structured grids, subtle shadows
|
||||
```
|
||||
## References
|
||||
|
||||
### technical
|
||||
```
|
||||
Design Aesthetic: Blueprint-style, precise, data-driven analytical presentation
|
||||
Background Color: Dark charcoal #1A1A2E
|
||||
Primary Font: JetBrains Mono (headlines)
|
||||
Secondary Font: IBM Plex Sans (body)
|
||||
Primary Text Color: Light gray #E8E8E8
|
||||
Primary Accent Color: Cyan #00D4FF
|
||||
Visual Elements: Grid overlays, circuit patterns, data visualizations, monospace code blocks
|
||||
```
|
||||
|
||||
### playful
|
||||
```
|
||||
Design Aesthetic: Bold, energetic, creative with dynamic visual interest
|
||||
Background Color: Warm white #FFFDF7
|
||||
Primary Font: Poppins (headlines)
|
||||
Secondary Font: Nunito (body)
|
||||
Primary Text Color: Deep purple #4A1D96
|
||||
Primary Accent Color: Vibrant coral #FF6B6B
|
||||
Visual Elements: Rounded shapes, gradients, illustrations, dynamic compositions, bright pops
|
||||
```
|
||||
|
||||
### minimal
|
||||
```
|
||||
Design Aesthetic: Ultra-clean, zen-like focus with elegant restraint
|
||||
Background Color: Pure white #FFFFFF
|
||||
Primary Font: Helvetica Neue (headlines)
|
||||
Secondary Font: Garamond (body)
|
||||
Primary Text Color: Pure black #000000
|
||||
Primary Accent Color: Single accent (content-derived)
|
||||
Visual Elements: Maximum whitespace, single focal points, hairline rules, elegant typography
|
||||
```
|
||||
|
||||
### storytelling
|
||||
```
|
||||
Design Aesthetic: Cinematic, narrative-driven with immersive visual flow
|
||||
Background Color: Rich charcoal #2D2D2D or full-bleed imagery
|
||||
Primary Font: Playfair Display (headlines)
|
||||
Secondary Font: Lora (body)
|
||||
Primary Text Color: Warm white #FAF9F6
|
||||
Primary Accent Color: Warm gold #D4AF37
|
||||
Visual Elements: Full-bleed photos, dramatic typography, chapter markers, emotional imagery
|
||||
```
|
||||
|
||||
### warm
|
||||
```
|
||||
Design Aesthetic: Cozy healing hand-drawn illustration style, soft sketch lines, warm and comforting atmosphere
|
||||
Background Color: Cream gradient #FFF8E7 → #FFE4C4
|
||||
Primary Font: Rounded hand-drawn style lettering
|
||||
Secondary Font: Soft serif hand lettering
|
||||
Primary Text Color: Warm brown #5D4037
|
||||
Primary Accent Color: Soft coral #FF8A80, Peachy pink #FFAB91
|
||||
Visual Elements: Soft watercolor textures, gentle curves, cozy illustrations, warm lighting, hearts and stars, plant motifs, cute characters
|
||||
```
|
||||
|
||||
### retro-flat
|
||||
```
|
||||
Design Aesthetic: Flat vector illustration with clear uniform-width black outlines, geometric simplification, toy-model cuteness
|
||||
Background Color: Off-white #F5F0E6, soft cream
|
||||
Primary Font: Bold geometric sans-serif with black outline
|
||||
Secondary Font: Clean sans-serif
|
||||
Primary Text Color: Black #000000 (clean outlines)
|
||||
Primary Accent Color: Retro muted palette - Dusty pink #E8B4B8, Sage green #A8C686, Mustard yellow #E8B84A, Muted blue #7BA7BC
|
||||
Visual Elements: Clear black monoline outlines (uniform width), flat color fills with NO gradients, geometric simplified shapes, toy-like proportions, minimal shadows, vintage badge style, halftone dots optional
|
||||
Style Rules: MUST use clear black outlines on all elements, NO 3D effects, NO gradients, simple flat color blocks only
|
||||
```
|
||||
|
||||
### notion
|
||||
```
|
||||
Design Aesthetic: Minimalist hand-drawn line art with intellectual SaaS product feel, Notion-style doodles
|
||||
Background Color: Pure white #FFFFFF, off-white #FAFAFA
|
||||
Primary Font: Clean hand-drawn lettering, simple sans-serif
|
||||
Secondary Font: Simple sans-serif labels
|
||||
Primary Text Color: Black #1A1A1A, dark gray #4A4A4A
|
||||
Primary Accent Color: Pastel blue #A8D4F0, Pastel yellow #F9E79F, Pastel pink #FADBD8
|
||||
Visual Elements: Simple line doodles with hand-drawn wobble effect, geometric shapes, stick figures, maximum whitespace, single-weight ink lines
|
||||
Style Rules: Single color lines (black/dark gray), 1-2 pastel accents only, NO complex gradients, NO heavy shadows, imperfect hand-drawn feel
|
||||
```
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `references/analysis-framework.md` | Deep content analysis for presentations |
|
||||
| `references/outline-template.md` | Outline structure and STYLE_INSTRUCTIONS format |
|
||||
| `references/modification-guide.md` | Edit, add, delete slide workflows |
|
||||
| `references/content-rules.md` | Content and style guidelines |
|
||||
| `references/base-prompt.md` | Base prompt for image generation |
|
||||
| `references/styles/<style>.md` | Full style specifications |
|
||||
|
||||
## Notes
|
||||
|
||||
### Design Philosophy
|
||||
- Deck is designed for **reading and sharing**, not live presentation
|
||||
- Structure should be self-explanatory without a presenter
|
||||
- Include enough context for visuals to be understood standalone
|
||||
- Err on the side of audience having **more expertise** than expected
|
||||
|
||||
### Content Rules
|
||||
- Maximum 20 slides per deck
|
||||
- Every data point must trace to source material
|
||||
- All details in prompts - image generator has no access to source
|
||||
|
||||
### Style Rules
|
||||
- Avoid AI-generated clichés ("It wasn't just X, it was Y")
|
||||
- Use narrative headlines, not "Title: Subtitle" format
|
||||
- Cover and Back Cover should be visually distinct (poster-style)
|
||||
- Back Cover should be meaningful closure, not "Thank You" or "Questions?"
|
||||
|
||||
### Prohibited
|
||||
- Never include photorealistic images of prominent individuals
|
||||
- Never include placeholder slides for author name, date, etc.
|
||||
|
||||
### Image Generation
|
||||
- Image generation typically takes 10-30 seconds per slide
|
||||
- Image generation: 10-30 seconds per slide
|
||||
- Auto-retry once on generation failure
|
||||
- Use cartoon alternatives for sensitive public figures
|
||||
- Output language matches content language
|
||||
- Maintain style consistency across all slides
|
||||
- Use stylized alternatives for sensitive public figures
|
||||
- Maintain style consistency via session ID
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
Create a presentation slide image following these guidelines:
|
||||
|
||||
## Image Specifications
|
||||
|
||||
- **Type**: Presentation slide
|
||||
- **Orientation**: Landscape (horizontal)
|
||||
- **Aspect Ratio**: 16:9
|
||||
- **Style**: Hand-drawn illustration with professional typography
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Hand-drawn quality throughout - NO realistic or photographic elements
|
||||
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
|
||||
- Clear visual hierarchy: headline dominates, supporting content secondary
|
||||
- Ample whitespace, avoid cluttered layouts
|
||||
- Professional presentation aesthetic
|
||||
|
||||
## Text Style (CRITICAL)
|
||||
|
||||
- **ALL text MUST be hand-drawn style**
|
||||
- Headlines: Large, bold, prominent
|
||||
- Body text: Clean, readable, well-spaced
|
||||
- Use visual emphasis for key terms (color, size, underline)
|
||||
- **DO NOT use realistic or computer-generated fonts**
|
||||
|
||||
## Layout Principles
|
||||
|
||||
- Consistent margins and spacing
|
||||
- Clear information hierarchy
|
||||
- Visual elements support but don't overwhelm text
|
||||
- Balance between text and imagery
|
||||
|
||||
## Language
|
||||
|
||||
- Use the same language as the content provided below
|
||||
- Match punctuation style to the content language
|
||||
|
||||
---
|
||||
|
||||
Please use nano banana pro to generate the slide based on the content provided below:
|
||||
@@ -0,0 +1,161 @@
|
||||
# Presentation Analysis Framework
|
||||
|
||||
Deep content analysis for effective slide deck creation.
|
||||
|
||||
## 1. Message Hierarchy
|
||||
|
||||
Identify the core message structure before designing slides.
|
||||
|
||||
### Core Message (One Sentence)
|
||||
- What is the single most important takeaway?
|
||||
- If the audience remembers only one thing, what should it be?
|
||||
- Can you state it in ≤15 words?
|
||||
|
||||
### Supporting Points (3-5 Maximum)
|
||||
- What evidence supports the core message?
|
||||
- What sub-topics must be covered?
|
||||
- Prioritize by audience relevance, not source order
|
||||
|
||||
### Call-to-Action
|
||||
- What should the audience DO after viewing?
|
||||
- Is it clear, specific, and achievable?
|
||||
- Where does it appear (slide position)?
|
||||
|
||||
## 2. Audience Decision Matrix
|
||||
|
||||
| Question | Analysis |
|
||||
|----------|----------|
|
||||
| Who is the primary audience? | [Role, expertise level, relationship to topic] |
|
||||
| What do they currently believe? | [Existing knowledge, assumptions, biases] |
|
||||
| What decision do we want them to make? | [Specific action or conclusion] |
|
||||
| What barriers exist? | [Objections, concerns, missing information] |
|
||||
| What evidence will convince them? | [Data types, credibility sources, emotional hooks] |
|
||||
|
||||
### Audience Adaptation
|
||||
|
||||
| Audience Type | Content Focus | Visual Treatment |
|
||||
|---------------|---------------|------------------|
|
||||
| Executives | Outcomes, ROI, strategic impact | High-level, clean, data highlights |
|
||||
| Technical | Architecture, implementation, specs | Detailed diagrams, code, schematics |
|
||||
| General | Benefits, stories, relatability | Visual metaphors, simple charts |
|
||||
| Investors | Market size, traction, team | Growth charts, milestones, comparisons |
|
||||
| Learners | Step-by-step, examples, practice | Progressive reveals, exercises |
|
||||
|
||||
## 3. Visual Opportunity Map
|
||||
|
||||
Identify which content benefits from visualization.
|
||||
|
||||
### Content-to-Visual Mapping
|
||||
|
||||
| Content Type | Visual Treatment | Example |
|
||||
|--------------|------------------|---------|
|
||||
| Comparisons | Side-by-side, before/after | Feature comparison table |
|
||||
| Processes | Flow diagrams, numbered steps | Workflow illustration |
|
||||
| Hierarchies | Org charts, pyramids, trees | Organizational structure |
|
||||
| Timelines | Horizontal/vertical timelines | Project milestones |
|
||||
| Statistics | Charts, highlighted numbers | Key metrics with context |
|
||||
| Concepts | Icons, metaphors, illustrations | Abstract idea visualization |
|
||||
| Relationships | Venn diagrams, networks | Ecosystem or dependencies |
|
||||
| Lists | Structured grids, icon rows | Feature bullets with icons |
|
||||
|
||||
### Visual Priority
|
||||
|
||||
Rate each piece of content:
|
||||
- **Must Visualize**: Complex data, key differentiators, memorable moments
|
||||
- **Should Visualize**: Supporting evidence, secondary points
|
||||
- **Text Only**: Simple statements, transitions, minor details
|
||||
|
||||
## 4. Presentation Flow
|
||||
|
||||
Structure for impact and retention.
|
||||
|
||||
### Opening (First 2-3 Slides)
|
||||
|
||||
| Element | Purpose |
|
||||
|---------|---------|
|
||||
| Hook | Capture attention (surprising stat, question, story) |
|
||||
| Context | Why this matters now |
|
||||
| Preview | What audience will learn/gain |
|
||||
|
||||
### Middle (Content Slides)
|
||||
|
||||
| Pattern | When to Use |
|
||||
|---------|-------------|
|
||||
| Problem → Solution | Introducing new products/ideas |
|
||||
| Situation → Complication → Resolution | Complex business cases |
|
||||
| What → Why → How | Educational content |
|
||||
| Past → Present → Future | Transformation stories |
|
||||
| Claim → Evidence → Implication | Data-driven arguments |
|
||||
|
||||
### Closing (Final 2-3 Slides)
|
||||
|
||||
| Element | Purpose |
|
||||
|---------|---------|
|
||||
| Synthesis | Tie back to core message |
|
||||
| Call-to-Action | Clear next steps |
|
||||
| Memorable Close | Resonant quote, image, or statement |
|
||||
|
||||
### Transitions
|
||||
|
||||
- Each slide should answer: "What comes next?"
|
||||
- Use narrative connectors between sections
|
||||
- Build logical progression, not topic jumps
|
||||
|
||||
## 5. Content Adaptation
|
||||
|
||||
Decide what to keep, transform, or omit.
|
||||
|
||||
### Keep (High Value)
|
||||
- Core arguments and evidence
|
||||
- Unique insights or data
|
||||
- Audience-relevant examples
|
||||
- Memorable quotes or statistics
|
||||
|
||||
### Simplify (Medium Value)
|
||||
- Technical details → Visual summaries
|
||||
- Long explanations → Bullet hierarchies
|
||||
- Multiple examples → Best 1-2 examples
|
||||
- Background context → Brief framing
|
||||
|
||||
### Visualize (Transform)
|
||||
- Data tables → Charts or highlighted numbers
|
||||
- Process descriptions → Flow diagrams
|
||||
- Comparisons in text → Side-by-side visuals
|
||||
- Abstract concepts → Concrete metaphors
|
||||
|
||||
### Omit (Low Value)
|
||||
- Tangential information
|
||||
- Redundant examples
|
||||
- Excessive caveats
|
||||
- Background the audience already knows
|
||||
|
||||
## 6. Analysis Checklist
|
||||
|
||||
Before outline creation, confirm:
|
||||
|
||||
### Message Clarity
|
||||
- [ ] Core message stated in one sentence
|
||||
- [ ] 3-5 supporting points identified
|
||||
- [ ] Call-to-action defined
|
||||
|
||||
### Audience Fit
|
||||
- [ ] Primary audience identified
|
||||
- [ ] Existing beliefs mapped
|
||||
- [ ] Desired decision clear
|
||||
- [ ] Evidence matches audience needs
|
||||
|
||||
### Visual Planning
|
||||
- [ ] Key visualizations identified
|
||||
- [ ] Chart/diagram types selected
|
||||
- [ ] Visual priority assigned
|
||||
|
||||
### Flow Design
|
||||
- [ ] Opening hook defined
|
||||
- [ ] Middle pattern selected
|
||||
- [ ] Closing approach planned
|
||||
- [ ] Transitions considered
|
||||
|
||||
### Content Decisions
|
||||
- [ ] Keep/simplify/visualize/omit applied
|
||||
- [ ] Source material fully processed
|
||||
- [ ] No important content overlooked
|
||||
@@ -0,0 +1,59 @@
|
||||
Create a presentation slide image following these guidelines:
|
||||
|
||||
## Image Specifications
|
||||
|
||||
- **Type**: Presentation slide
|
||||
- **Aspect Ratio**: 16:9 (landscape)
|
||||
- **Style**: Professional slide deck
|
||||
|
||||
## Core Persona: The Architect
|
||||
|
||||
You are "The Architect" - a master visual storyteller creating presentation slides. Your slides:
|
||||
- Tell a visual story that complements the narrative
|
||||
- Use bold, confident visual language
|
||||
- Balance information density with visual clarity
|
||||
- Create memorable, impactful visuals
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Hand-drawn quality throughout - NO realistic or photographic elements
|
||||
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
|
||||
- NO slide numbers, page numbers, footers, headers, or logos
|
||||
- Clean, uncluttered layouts with clear visual hierarchy
|
||||
- Each slide conveys ONE clear message
|
||||
|
||||
## Text Style (CRITICAL)
|
||||
|
||||
- **ALL text MUST match the designated style exactly**
|
||||
- Title text: Large, bold, immediately readable
|
||||
- Body text: Clear, legible, appropriate sizing
|
||||
- Max 3-4 text elements per slide
|
||||
- **DO NOT use realistic or computer-generated fonts unless style specifies**
|
||||
- **Font rendering must match the style aesthetic** (hand-drawn for sketch styles, clean for minimal styles)
|
||||
|
||||
## Layout Principles
|
||||
|
||||
- **Visual Hierarchy**: Most important element gets most visual weight
|
||||
- **Breathing Room**: Generous margins and spacing between elements
|
||||
- **Alignment**: Consistent alignment creates professional feel
|
||||
- **Balance**: Distribute visual weight evenly (symmetrical or asymmetrical)
|
||||
- **Focal Point**: One clear area draws the eye first
|
||||
- **Rule of Thirds**: Key elements at intersection points for dynamic compositions
|
||||
- **Z-Pattern**: For text-heavy slides, arrange content in natural reading flow
|
||||
|
||||
## Language
|
||||
|
||||
- Use the same language as the content provided below for all text elements
|
||||
- Match punctuation style to the content language
|
||||
- Write in direct, confident language
|
||||
- Avoid AI-sounding phrases like "dive into", "explore", "let's", "journey"
|
||||
|
||||
---
|
||||
|
||||
## STYLE_INSTRUCTIONS
|
||||
|
||||
[Insert style-specific instructions here]
|
||||
|
||||
---
|
||||
|
||||
Please use nano banana pro to generate the slide image based on the content provided below:
|
||||
@@ -0,0 +1,95 @@
|
||||
# Content & Style Rules
|
||||
|
||||
Guidelines for slide deck content quality and style consistency.
|
||||
|
||||
## Content Rules
|
||||
|
||||
### 1. Respect Reader Attention
|
||||
- Each slide should communicate ONE main idea
|
||||
- Remove redundant information
|
||||
- Prioritize clarity over comprehensiveness
|
||||
|
||||
### 2. Data Traceability
|
||||
- All statistics must include source attribution
|
||||
- Cite sources directly on slides with data
|
||||
- Use specific numbers over vague claims
|
||||
|
||||
### 3. Self-Contained Prompts
|
||||
- Every detail must be in the image prompt
|
||||
- No external references (e.g., "like slide 2")
|
||||
- Include all colors, layouts, and content explicitly
|
||||
|
||||
### 4. No Placeholders
|
||||
- Every element must be fully specified
|
||||
- No "[insert data here]" or "TBD"
|
||||
- All text content finalized before generation
|
||||
|
||||
## Style Rules
|
||||
|
||||
### 1. Narrative Headlines
|
||||
Headlines tell the story, not label the content.
|
||||
|
||||
| Bad | Good |
|
||||
|-----|------|
|
||||
| "Key Statistics" | "Usage doubled in 6 months" |
|
||||
| "Our Solution" | "One platform replaces five tools" |
|
||||
| "Benefits" | "Teams save 10 hours weekly" |
|
||||
|
||||
### 2. Avoid AI Clichés
|
||||
Remove these patterns:
|
||||
- "Dive into", "explore", "journey"
|
||||
- "Let's look at", "let me show you"
|
||||
- "Exciting", "amazing", "revolutionary"
|
||||
- "In conclusion", "to summarize"
|
||||
|
||||
### 3. Meaningful Back Cover
|
||||
Not just "Thank you" or "Questions?"
|
||||
|
||||
Include one of:
|
||||
- Clear call-to-action
|
||||
- Memorable key takeaway
|
||||
- Thought-provoking closing statement
|
||||
- Contact information with purpose
|
||||
|
||||
### 4. Consistent Visual Language
|
||||
Throughout the deck:
|
||||
- Same icon style
|
||||
- Same color usage patterns
|
||||
- Same layout grid system
|
||||
- Same typography hierarchy
|
||||
|
||||
## Slide Structure
|
||||
|
||||
| Position | Type | Purpose |
|
||||
|----------|------|---------|
|
||||
| 1 | Cover | Title, visual hook, topic introduction |
|
||||
| 2 to N-1 | Content | Key points, data, explanations |
|
||||
| N | Back Cover | Summary, call-to-action, memorable close |
|
||||
|
||||
## Key Specifications
|
||||
|
||||
| Specification | Value |
|
||||
|---------------|-------|
|
||||
| Aspect Ratio | 16:9 (landscape) |
|
||||
| Slide Count | Dynamic based on content |
|
||||
| Required Slides | Cover + Back Cover minimum |
|
||||
| Footers | None (no slide numbers, logos) |
|
||||
| Language Priority | `--lang` → source language → ask user |
|
||||
| Tone | Direct, confident (avoid AI phrases) |
|
||||
|
||||
## Style Quick Reference
|
||||
|
||||
| Style | Visual Summary |
|
||||
|-------|----------------|
|
||||
| `sketch-notes` | Hand-drawn, warm off-white, conceptual icons |
|
||||
| `blueprint` | Technical schematics, grid texture, blue tones |
|
||||
| `bold-editorial` | High contrast, dark backgrounds, magazine impact |
|
||||
| `vector-illustration` | Flat vector, black outlines, retro colors |
|
||||
| `minimal` | Maximum whitespace, single accent, zen-like |
|
||||
| `storytelling` | Full-bleed imagery, cinematic, emotional |
|
||||
| `warm` | Soft gradients, rounded shapes, wellness palette |
|
||||
| `notion` | Dashboard aesthetic, clean data viz, SaaS-inspired |
|
||||
| `corporate` | Navy/gold, structured layouts, business polish |
|
||||
| `playful` | Vibrant coral/teal/yellow, dynamic, energetic |
|
||||
|
||||
Full style specifications: `references/styles/<style>.md`
|
||||
@@ -0,0 +1,85 @@
|
||||
# Slide Modification Guide
|
||||
|
||||
Workflows for modifying individual slides after initial generation.
|
||||
|
||||
## Edit Single Slide
|
||||
|
||||
Regenerate a specific slide with modified content:
|
||||
|
||||
1. Identify slide to edit (e.g., `03-slide-key-findings.png`)
|
||||
2. Update prompt in `prompts/03-slide-key-findings.md`
|
||||
3. If content changes significantly, update slug in filename
|
||||
4. Regenerate image using same session ID
|
||||
5. Regenerate PPTX and PDF
|
||||
|
||||
## Add New Slide
|
||||
|
||||
Insert a new slide at specified position:
|
||||
|
||||
1. Specify insertion position (e.g., after slide 3)
|
||||
2. Create new prompt with appropriate slug (e.g., `04-slide-new-section.md`)
|
||||
3. Generate new slide image
|
||||
4. **Renumber files**: All subsequent slides increment NN by 1
|
||||
- `04-slide-conclusion.png` → `05-slide-conclusion.png`
|
||||
- Slugs remain unchanged
|
||||
5. Update `outline.md` with new slide entry
|
||||
6. Regenerate PPTX and PDF
|
||||
|
||||
## Delete Slide
|
||||
|
||||
Remove a slide and renumber:
|
||||
|
||||
1. Identify slide to delete (e.g., `03-slide-key-findings.png`)
|
||||
2. Remove image file and prompt file
|
||||
3. **Renumber files**: All subsequent slides decrement NN by 1
|
||||
- `04-slide-conclusion.png` → `03-slide-conclusion.png`
|
||||
- Slugs remain unchanged
|
||||
4. Update `outline.md` to remove slide entry
|
||||
5. Regenerate PPTX and PDF
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
Files use meaningful slugs for better readability:
|
||||
|
||||
```
|
||||
NN-slide-[slug].png
|
||||
NN-slide-[slug].md (in prompts/)
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `01-slide-cover.png`
|
||||
- `02-slide-problem-statement.png`
|
||||
- `03-slide-key-findings.png`
|
||||
- `04-slide-back-cover.png`
|
||||
|
||||
## Slug Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| Format | Kebab-case (lowercase, hyphens) |
|
||||
| Source | Derived from slide title/content |
|
||||
| Uniqueness | Must be unique within the deck |
|
||||
| Updates | Change slug when content changes significantly |
|
||||
|
||||
## Renumbering Rules
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Add slide | Increment NN for all subsequent slides |
|
||||
| Delete slide | Decrement NN for all subsequent slides |
|
||||
| Reorder slides | Update NN to match new positions |
|
||||
| Edit slide | NN unchanged, update slug if needed |
|
||||
|
||||
**Important**: Slugs remain unchanged during renumbering. Only the NN prefix changes.
|
||||
|
||||
## Post-Modification Checklist
|
||||
|
||||
After any modification:
|
||||
|
||||
- [ ] Image file renamed/created correctly
|
||||
- [ ] Prompt file renamed/created correctly
|
||||
- [ ] Subsequent files renumbered (if add/delete)
|
||||
- [ ] `outline.md` updated to reflect changes
|
||||
- [ ] PPTX regenerated
|
||||
- [ ] PDF regenerated
|
||||
- [ ] Slide count in outline header updated
|
||||
@@ -0,0 +1,164 @@
|
||||
# Outline Template
|
||||
|
||||
Standard structure for slide deck outlines with style instructions.
|
||||
|
||||
## Outline Format
|
||||
|
||||
```markdown
|
||||
# Slide Deck Outline
|
||||
|
||||
**Topic**: [topic description]
|
||||
**Style**: [selected style]
|
||||
**Audience**: [target audience]
|
||||
**Language**: [output language]
|
||||
**Slide Count**: N slides
|
||||
**Generated**: YYYY-MM-DD HH:mm
|
||||
|
||||
---
|
||||
|
||||
<STYLE_INSTRUCTIONS>
|
||||
Design Aesthetic: [2-3 sentence description from style file]
|
||||
|
||||
Background:
|
||||
Color: [Name] ([Hex])
|
||||
Texture: [description]
|
||||
|
||||
Typography:
|
||||
Primary Font: [detailed description for image generation]
|
||||
Secondary Font: [detailed description for image generation]
|
||||
|
||||
Color Palette:
|
||||
Primary Text: [Name] ([Hex]) - [usage]
|
||||
Background: [Name] ([Hex]) - [usage]
|
||||
Accent 1: [Name] ([Hex]) - [usage]
|
||||
Accent 2: [Name] ([Hex]) - [usage]
|
||||
|
||||
Visual Elements:
|
||||
- [element 1 with rendering guidance]
|
||||
- [element 2 with rendering guidance]
|
||||
- ...
|
||||
|
||||
Style Rules:
|
||||
Do: [guidelines from style file]
|
||||
Don't: [anti-patterns from style file]
|
||||
</STYLE_INSTRUCTIONS>
|
||||
|
||||
---
|
||||
|
||||
[Slide entries follow...]
|
||||
```
|
||||
|
||||
## Cover Slide Template
|
||||
|
||||
```markdown
|
||||
## Slide 1 of N
|
||||
|
||||
**Type**: Cover
|
||||
**Filename**: 01-slide-cover.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[What this slide achieves in the story arc]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [main title]
|
||||
Sub-headline: [supporting tagline]
|
||||
|
||||
// VISUAL
|
||||
[Detailed visual description - specific elements, composition, mood]
|
||||
|
||||
// LAYOUT
|
||||
[Composition, hierarchy, spatial arrangement]
|
||||
```
|
||||
|
||||
## Content Slide Template
|
||||
|
||||
```markdown
|
||||
## Slide X of N
|
||||
|
||||
**Type**: Content
|
||||
**Filename**: {NN}-slide-{slug}.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[What this slide achieves in the story arc]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [main message - narrative, not label]
|
||||
Sub-headline: [supporting context]
|
||||
Body:
|
||||
- [point 1 with specific detail]
|
||||
- [point 2 with specific detail]
|
||||
- [point 3 with specific detail]
|
||||
|
||||
// VISUAL
|
||||
[Detailed visual description]
|
||||
|
||||
// LAYOUT
|
||||
[Composition, hierarchy, spatial arrangement]
|
||||
```
|
||||
|
||||
## Back Cover Slide Template
|
||||
|
||||
```markdown
|
||||
## Slide N of N
|
||||
|
||||
**Type**: Back Cover
|
||||
**Filename**: {NN}-slide-back-cover.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[Meaningful closing - not just "thank you"]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [memorable closing statement or call-to-action]
|
||||
Body: [optional summary points or next steps]
|
||||
|
||||
// VISUAL
|
||||
[Visual that reinforces the core message]
|
||||
|
||||
// LAYOUT
|
||||
[Clean, impactful composition]
|
||||
```
|
||||
|
||||
## STYLE_INSTRUCTIONS Block
|
||||
|
||||
The `<STYLE_INSTRUCTIONS>` block contains all style-specific guidance for image generation:
|
||||
|
||||
| Section | Content |
|
||||
|---------|---------|
|
||||
| Design Aesthetic | Overall visual direction from style file |
|
||||
| Background | Base color and texture details |
|
||||
| Typography | Font descriptions for Gemini (no font names, describe appearance) |
|
||||
| Color Palette | Named colors with hex codes and usage guidance |
|
||||
| Visual Elements | Specific graphic elements with rendering instructions |
|
||||
| Style Rules | Do/Don't guidelines from style file |
|
||||
|
||||
**Important**: Typography descriptions must describe the visual appearance (e.g., "rounded sans-serif", "bold geometric") since image generators cannot use font names.
|
||||
|
||||
## Section Dividers
|
||||
|
||||
Use `---` (horizontal rule) between:
|
||||
- Header metadata and STYLE_INSTRUCTIONS
|
||||
- STYLE_INSTRUCTIONS and first slide
|
||||
- Each slide entry
|
||||
|
||||
## Slide Numbering
|
||||
|
||||
- Cover is always Slide 1
|
||||
- Content slides use sequential numbers
|
||||
- Back Cover is always final slide (N)
|
||||
- Filename prefix matches slide position: `01-`, `02-`, etc.
|
||||
|
||||
## Filename Slugs
|
||||
|
||||
Generate meaningful slugs from slide content:
|
||||
|
||||
| Slide Type | Slug Pattern | Example |
|
||||
|------------|--------------|---------|
|
||||
| Cover | `cover` | `01-slide-cover.png` |
|
||||
| Content | `{topic-slug}` | `02-slide-problem-statement.png` |
|
||||
| Back Cover | `back-cover` | `10-slide-back-cover.png` |
|
||||
|
||||
Slug rules:
|
||||
- Kebab-case (lowercase, hyphens)
|
||||
- Derived from headline or main topic
|
||||
- Maximum 30 characters
|
||||
- Unique within deck
|
||||
@@ -0,0 +1,67 @@
|
||||
# blueprint
|
||||
|
||||
Precise technical blueprint style with professional analytical visual presentation
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Clean, structured visual metaphors using blueprints, diagrams, and schematics. Precise, analytical and aesthetically refined. Information presented in triptych or grid-based layouts with engineering precision.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Blueprint Off-White (#FAF8F5)
|
||||
- Texture: Subtle grid overlay, light engineering paper feel
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
Neue Haas Grotesk Display Pro or similar clean sans-serif. Bold weight for titles. Precise letterforms with consistent spacing. Technical, authoritative presence.
|
||||
|
||||
### Secondary Font (Body)
|
||||
|
||||
Tiempos Text or similar elegant serif for body explanations. Clean, readable at smaller sizes. Professional editorial quality.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Blueprint Paper | #FAF8F5 | Primary background |
|
||||
| Grid | Light Gray | #E5E5E5 | Background grid lines |
|
||||
| Primary Text | Deep Slate | #334155 | Headlines, body text |
|
||||
| Primary Accent | Engineering Blue | #2563EB | Key elements, highlights |
|
||||
| Secondary Accent | Navy Blue | #1E3A5F | Supporting elements |
|
||||
| Tertiary | Light Blue | #BFDBFE | Backgrounds, fills |
|
||||
| Warning | Amber | #F59E0B | Warnings, emphasis points |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Precise lines with consistent stroke weights
|
||||
- Technical schematics and clean vector graphics
|
||||
- Thin line work in technical drawing style
|
||||
- Connection lines use straight lines or 90-degree angles only
|
||||
- Data visualization with clean, minimal charts
|
||||
- Dimension lines and measurement indicators
|
||||
- Cross-section style diagrams
|
||||
- Isometric or orthographic projections
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Maintain consistent line weights throughout
|
||||
- Use grid alignment for all elements
|
||||
- Keep color palette restrained and unified
|
||||
- Create clear visual hierarchy through scale
|
||||
- Use geometric precision for all shapes
|
||||
|
||||
### Don't
|
||||
|
||||
- Use hand-drawn or organic shapes
|
||||
- Add decorative flourishes
|
||||
- Use curved connection lines
|
||||
- Include photographic elements
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Technical architecture, system design, data analysis, professional business presentations, engineering documentation, process flows
|
||||
@@ -0,0 +1,69 @@
|
||||
# bold-editorial
|
||||
|
||||
High-impact magazine editorial style with bold visual expression
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Strong visual impact at magazine cover level. Bold typography and dramatic contrast. Full-bleed imagery and large color blocks create commanding presence. Every slide feels like a premium publication cover.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Deep Black (#0A0A0A) primary, or Deep Blue (#0F172A) alternative
|
||||
- Texture: None - clean solid backgrounds, or pure white with bold color blocks
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
Bold condensed typeface like Impact, Oswald Bold, or Bebas Neue. Oversized headlines that dominate the slide. All-caps for maximum impact. Tight letter-spacing.
|
||||
|
||||
### Secondary Font (Body)
|
||||
|
||||
Clean sans-serif such as Inter, SF Pro, or Helvetica Neue. Medium weight for body text. High contrast against background.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background Dark | Deep Black | #0A0A0A | Primary dark background |
|
||||
| Background Alt | Deep Blue | #0F172A | Alternative dark background |
|
||||
| Background Light | Pure White | #FFFFFF | Light mode background |
|
||||
| Primary Text | Pure White | #FFFFFF | Text on dark backgrounds |
|
||||
| Alt Text | Pure Black | #000000 | Text on light backgrounds |
|
||||
| Accent 1 | Electric Blue | #3B82F6 | Primary highlights |
|
||||
| Accent 2 | Bright Orange | #FB923C | Energy, urgency |
|
||||
| Accent 3 | Magenta | #EC4899 | Creative, bold accents |
|
||||
| Accent 4 | Neon Green | #22C55E | Success, growth |
|
||||
| Accent 5 | Violet | #8B5CF6 | Innovation, premium |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Strong typography as visual element itself
|
||||
- Geometric shapes and bold color blocks
|
||||
- Full-bleed images or solid color backgrounds
|
||||
- High contrast gradients (subtle, not garish)
|
||||
- Minimal decoration - let content speak
|
||||
- Dynamic diagonal lines and angles
|
||||
- Dramatic lighting effects on text
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Use extreme scale contrast (huge headlines, small body)
|
||||
- Create bold color block compositions
|
||||
- Let negative space create tension
|
||||
- Use full-bleed backgrounds
|
||||
- Make every slide feel like a magazine cover
|
||||
|
||||
### Don't
|
||||
|
||||
- Use soft or muted colors
|
||||
- Add unnecessary decorative elements
|
||||
- Create busy, cluttered layouts
|
||||
- Use thin or delicate typography
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Product launches, marketing presentations, keynote speeches, brand showcases, investor pitches, high-stakes presentations
|
||||
@@ -0,0 +1,69 @@
|
||||
# corporate
|
||||
|
||||
Professional business style with navy/gold palette and structured layouts
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Clean lines, structured layouts, and business-appropriate sophistication. Projects competence, reliability, and institutional credibility. Balances professionalism with approachability through careful use of whitespace and refined color choices.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Pure White (#FFFFFF) with navy structural elements
|
||||
- Texture: None - crisp digital clarity for maximum professionalism
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
Modern geometric sans-serif (Inter, SF Pro, or similar). Clean, professional, and highly legible. Conveys competence and contemporary business sensibility. Medium to semi-bold weight.
|
||||
|
||||
### Secondary Font (Body)
|
||||
|
||||
Humanist sans-serif (Source Sans Pro style) for body text. Friendly yet professional, optimized for reading comprehension. Regular weight with comfortable line height.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Pure White | #FFFFFF | Main slide background |
|
||||
| Primary Text | Navy | #1E3A5F | Headlines, key text |
|
||||
| Secondary Text | Dark Gray | #4A5568 | Body text |
|
||||
| Primary Accent | Gold | #C9A227 | Premium highlights, emphasis |
|
||||
| Secondary Accent | Light Navy | #3D5A80 | Secondary elements |
|
||||
| Success | Corporate Green | #059669 | Positive metrics |
|
||||
| Alert | Corporate Red | #DC2626 | Attention items |
|
||||
| Neutral | Light Gray | #F3F4F6 | Background sections |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Clean charts and data visualizations
|
||||
- Professional iconography (outlined style)
|
||||
- Structured grid layouts
|
||||
- Subtle shadows for depth (minimal)
|
||||
- Progress bars and metrics displays
|
||||
- Organizational charts
|
||||
- Timeline graphics
|
||||
- Comparison tables
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Maintain clear visual hierarchy
|
||||
- Use consistent grid alignment
|
||||
- Apply accent colors strategically (gold for emphasis)
|
||||
- Keep data visualizations clean and readable
|
||||
- Use professional outlined iconography
|
||||
|
||||
### Don't
|
||||
|
||||
- Use playful or casual elements
|
||||
- Apply heavy decorative effects
|
||||
- Mix too many accent colors
|
||||
- Crowd slides with information
|
||||
- Use informal illustration styles
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Business presentations, investor decks, quarterly reports, executive summaries, client proposals, corporate communications, board meetings
|
||||
@@ -0,0 +1,64 @@
|
||||
# minimal
|
||||
|
||||
Ultra-clean keynote style with maximum whitespace and zen-like simplicity
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Maximum whitespace with minimal elements. Zen-like simplicity where every element earns its place. Premium, refined aesthetic suitable for executive audiences. Less is more - remove until nothing more can be taken away.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Pure White (#FFFFFF)
|
||||
- Texture: None - absolute clean, no grain or patterns
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
Clean geometric sans-serif like SF Pro Display, Inter, or Helvetica Neue. Light to medium weight for elegant restraint. Generous letter-spacing. Large scale for impact without boldness.
|
||||
|
||||
### Secondary Font (Body)
|
||||
|
||||
Same family as headlines in lighter weight. Minimal size contrast. Clean, airy feeling throughout.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Pure White | #FFFFFF | Primary background |
|
||||
| Primary Text | Near Black | #1A1A1A | Headlines, body |
|
||||
| Secondary Text | Medium Gray | #6B7280 | Captions, metadata |
|
||||
| Accent | Single Brand Color | #2563EB | One accent only, sparingly |
|
||||
| Dividers | Light Gray | #E5E7EB | Subtle separators |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Single accent color used sparingly
|
||||
- Thin hairline rules for separation
|
||||
- Generous margins (minimum 15% on all sides)
|
||||
- Center or left-aligned layouts
|
||||
- Simple geometric shapes only when necessary
|
||||
- No decorative elements
|
||||
- Data visualizations in single color or grayscale
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Embrace empty space as a design element
|
||||
- Use single accent color only
|
||||
- Keep text minimal (10 words or less per slide)
|
||||
- Create breathing room between elements
|
||||
- Use scale to create hierarchy
|
||||
|
||||
### Don't
|
||||
|
||||
- Fill empty space with decoration
|
||||
- Use multiple accent colors
|
||||
- Add icons or illustrations unless essential
|
||||
- Create dense information layouts
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Executive briefings, keynote presentations, premium brand communications, minimalist products, investor meetings, high-level strategy
|
||||
@@ -0,0 +1,69 @@
|
||||
# notion
|
||||
|
||||
SaaS dashboard aesthetic with clean data focus and productivity tool styling
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Clean, functional SaaS interface aesthetic. Dashboard-inspired layouts with clear data hierarchy. Notion, Linear, and modern productivity tool styling. Information-dense but organized. Professional and trustworthy.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Light Gray (#F7F7F5) or Pure White (#FFFFFF)
|
||||
- Texture: None - clean solid backgrounds
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
System UI stack or Inter. Semi-bold weight for emphasis. Clean, functional letterforms. Slightly tighter letter-spacing.
|
||||
|
||||
### Secondary Font (Body)
|
||||
|
||||
Same family in regular weight. Optimized for screen reading. Comfortable line height (1.5-1.6).
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Light Gray | #F7F7F5 | Primary background |
|
||||
| Card Background | Pure White | #FFFFFF | Content cards |
|
||||
| Primary Text | Near Black | #1F1F1F | Headlines, body |
|
||||
| Secondary Text | Gray | #6B6B6B | Metadata, labels |
|
||||
| Border | Light Border | #E5E5E5 | Card borders, dividers |
|
||||
| Accent Blue | Notion Blue | #2383E2 | Links, primary actions |
|
||||
| Accent Green | Success | #0F7B6C | Positive metrics |
|
||||
| Accent Red | Alert | #E03E3E | Negative metrics |
|
||||
| Accent Yellow | Warning | #DFAB01 | Cautions |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Card-based layouts with subtle borders or shadows
|
||||
- Clean data tables and charts
|
||||
- Progress bars and metric displays
|
||||
- Icon-based navigation hints
|
||||
- Checkbox and toggle styling
|
||||
- Tag and label chips
|
||||
- Subtle hover state styling
|
||||
- Breadcrumb and hierarchy indicators
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Use card-based content organization
|
||||
- Create clear data hierarchy
|
||||
- Use subtle shadows and borders
|
||||
- Keep layouts grid-aligned
|
||||
- Present metrics prominently
|
||||
|
||||
### Don't
|
||||
|
||||
- Use decorative illustrations
|
||||
- Add gradients or complex backgrounds
|
||||
- Create artistic layouts
|
||||
- Use rounded blob shapes
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Product demos, SaaS presentations, productivity tool pitches, metrics dashboards, feature walkthroughs, B2B presentations, technical product marketing
|
||||
@@ -0,0 +1,69 @@
|
||||
# playful
|
||||
|
||||
Bold, energetic style with vibrant colors and dynamic shapes
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Dynamic shapes, vibrant colors, and creative layouts that capture attention and spark joy. Approachable and fun without sacrificing clarity. Perfect for content that needs to educate while entertaining, making complex topics feel accessible and exciting.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Warm White (#FFFDF7), soft and inviting
|
||||
- Texture: Light subtle pattern or clean gradient
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
Bold rounded sans-serif (Poppins, Nunito Bold, or similar). Friendly, modern, and highly readable. Conveys energy and approachability with geometric character.
|
||||
|
||||
### Secondary Font (Body)
|
||||
|
||||
Rounded sans-serif (Nunito style) for body text. Soft edges create warmth and readability, complementing the playful aesthetic.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Warm White | #FFFDF7 | Main slide background |
|
||||
| Primary Text | Deep Purple | #4A1D96 | Headlines, key text |
|
||||
| Secondary Text | Dark Purple | #6B3FA0 | Body text |
|
||||
| Primary Accent | Vibrant Coral | #FF6B6B | Primary highlights |
|
||||
| Secondary Accent | Electric Teal | #4ECDC4 | Secondary emphasis |
|
||||
| Tertiary | Sunshine Yellow | #FFE66D | Tertiary accent |
|
||||
| Success | Mint Green | #95E1C3 | Positive elements |
|
||||
| Energy | Hot Pink | #FF69B4 | High energy callouts |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Rounded shapes and soft corners everywhere
|
||||
- Playful gradients (subtle, not overwhelming)
|
||||
- Character illustrations and mascots
|
||||
- Dynamic asymmetrical compositions
|
||||
- Bright color pops and accents
|
||||
- Confetti and celebration elements
|
||||
- Emoji-style icons
|
||||
- Bubble and cloud shapes
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Use bold, contrasting colors
|
||||
- Include rounded, friendly shapes
|
||||
- Create dynamic, engaging layouts
|
||||
- Add implied motion and energy
|
||||
- Keep typography large and readable
|
||||
|
||||
### Don't
|
||||
|
||||
- Use sharp, aggressive shapes
|
||||
- Apply dark or somber colors
|
||||
- Create static, boring layouts
|
||||
- Overload with too many elements
|
||||
- Make it look unprofessional
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Creative pitches, educational workshops, training materials, community presentations, product launches, children's content, team building, startup culture
|
||||
@@ -0,0 +1,66 @@
|
||||
# sketch-notes
|
||||
|
||||
Soft hand-drawn illustration style with fresh, refined minimalist editorial aesthetic
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Illustration or hand-drawn feel with soft, relaxed brush strokes. Fresh, refined overall style with minimalist editorial approach. Emphasis on precision, clarity and intelligent elegance while prioritizing warmth, approachability and friendliness.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Warm Off-White (#FAF8F0)
|
||||
- Texture: Subtle paper grain, slightly warm tone to avoid clinical feel
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
Bold hand-written marker font or cartoon poster font. Slightly uneven baseline for organic feel. Thick strokes with soft edges. Render as hand-drawn letters, not typed text.
|
||||
|
||||
### Secondary Font (Body)
|
||||
|
||||
Clear handwritten round or hard-pen style mimicking everyday notes. Consistent sizing with slight natural variation. Render as casual handwriting, legible but not mechanical.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Warm Off-White | #FAF8F0 | Primary background |
|
||||
| Primary Text | Deep Charcoal | #2C3E50 | Headlines, body text |
|
||||
| Alt Text | Deep Brown | #4A4A4A | Secondary text elements |
|
||||
| Accent 1 | Soft Orange | #F4A261 | Highlights, emphasis |
|
||||
| Accent 2 | Mustard Yellow | #E9C46A | Secondary highlights |
|
||||
| Accent 3 | Sage Green | #87A96B | Nature, growth concepts |
|
||||
| Accent 4 | Light Blue | #7EC8E3 | Tech, AI elements |
|
||||
| Accent 5 | Red Brown | #A0522D | Land, infrastructure |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Connection lines with hand-drawn wavy feel, not perfectly straight
|
||||
- Conceptual abstract icons illustrating ideas rather than literal scenes
|
||||
- Color fills don't need to completely fill outlines - preserve hand-painted casual feel
|
||||
- Simple geometric shapes with rounded corners
|
||||
- Arrows and pointers with sketchy, informal style
|
||||
- Doodle-style decorative elements: stars, spirals, underlines
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Keep layouts open and well-structured
|
||||
- Emphasize information hierarchy and readability
|
||||
- Use hand-drawn quality for all elements
|
||||
- Allow imperfection - slight wobbles add character
|
||||
- Layer elements with subtle overlaps
|
||||
|
||||
### Don't
|
||||
|
||||
- Use perfect geometric shapes
|
||||
- Create photorealistic elements
|
||||
- Overcrowd with too many elements
|
||||
- Use pure white backgrounds
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, knowledge sharing, technical explanations, friendly presentations, tutorials, onboarding materials
|
||||
@@ -0,0 +1,67 @@
|
||||
# storytelling
|
||||
|
||||
Cinematic narrative style with full-bleed visuals and emotional impact
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Cinematic, full-bleed imagery that creates emotional connection. Story-driven compositions that guide the viewer through a narrative arc. Each slide is a scene in a larger story. Photography and illustration work together to create atmosphere.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Varies by mood - dark for drama (#0F0F0F), warm for comfort (#FDF8F3)
|
||||
- Texture: Photographic or illustrated backgrounds, subtle vignetting
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
Elegant serif like Playfair Display, Libre Baskerville, or editorial serif. Large, expressive headlines that complement imagery. Can use italic for emotional emphasis.
|
||||
|
||||
### Secondary Font (Body)
|
||||
|
||||
Clean sans-serif for contrast and readability. Light weight to not compete with imagery. White or dark depending on background.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background Dark | Near Black | #0F0F0F | Dramatic scenes |
|
||||
| Background Warm | Warm Cream | #FDF8F3 | Comfortable scenes |
|
||||
| Primary Text Light | Off-White | #FAFAFA | Text on dark |
|
||||
| Primary Text Dark | Deep Charcoal | #1F1F1F | Text on light |
|
||||
| Accent Warm | Golden | #D4A853 | Warm highlights |
|
||||
| Accent Cool | Teal | #2DD4BF | Cool accents |
|
||||
| Overlay | Black 40% | rgba(0,0,0,0.4) | Text legibility |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Full-bleed photographic or illustrated backgrounds
|
||||
- Cinematic aspect compositions (rule of thirds)
|
||||
- Text overlays with subtle background darkening
|
||||
- Atmospheric lighting and mood
|
||||
- People and human elements when possible
|
||||
- Emotional color grading
|
||||
- Subtle motion blur or depth of field effects
|
||||
- Vignetting to draw focus
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Create emotional connection through imagery
|
||||
- Use photography or illustration as primary element
|
||||
- Let visuals tell the story
|
||||
- Create consistent mood throughout deck
|
||||
- Use text sparingly - images speak
|
||||
|
||||
### Don't
|
||||
|
||||
- Use stock photo aesthetic
|
||||
- Crowd slides with text
|
||||
- Mix conflicting visual moods
|
||||
- Use clip art or basic shapes
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Case studies, brand stories, narrative presentations, emotional pitches, documentary-style content, origin stories, customer journeys
|
||||
@@ -0,0 +1,72 @@
|
||||
# vector-illustration
|
||||
|
||||
Flat vector illustration style with clear black outlines and retro soft color palette
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Flat vector illustration with no gradients or 3D effects. Clear, uniform-thickness black outlines on all elements. Geometric simplification reducing complex objects to basic shapes. Toy model aesthetic that's cute, playful, and approachable. Panoramic horizontal compositions work well.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Cream Off-White (#F5F0E6)
|
||||
- Texture: Subtle paper texture, warm nostalgic feel reminiscent of vintage prints
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
Large, bold retro serif for titles conveying authority and elegance. Think classic advertising posters. Clean letterforms with strong presence.
|
||||
|
||||
### Secondary Font (Subtitles)
|
||||
|
||||
All-caps sans-serif inside colored rectangular blocks. Label-like appearance. High contrast against block color.
|
||||
|
||||
### Body Font
|
||||
|
||||
Clean geometric sans-serif for readability. Futura, Avenir, or similar. Consistent weight throughout.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Cream Off-White | #F5F0E6 | Primary background |
|
||||
| Outlines | Deep Charcoal | #2D2D2D | All element outlines |
|
||||
| Primary Text | Black | #1A1A1A | Headlines, body |
|
||||
| Accent 1 | Coral Red | #E07A5F | Primary accent, warmth |
|
||||
| Accent 2 | Mint Green | #81B29A | Secondary accent, nature |
|
||||
| Accent 3 | Mustard Yellow | #F2CC8F | Highlights, energy |
|
||||
| Accent 4 | Burnt Orange | #D4764A | Tertiary accent |
|
||||
| Accent 5 | Rock Blue | #577590 | Cool balance, tech |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- All objects have closed black outlines (coloring book style)
|
||||
- Rounded line endings, avoid sharp corners
|
||||
- Trees simplified to lollipop or triangle shapes
|
||||
- Buildings simplified to rectangular blocks with grid windows
|
||||
- 2.5D perspective (isometric-like but more free-form)
|
||||
- Depth through layering and overlap, not atmospheric perspective
|
||||
- Decorative geometric elements: radiating lines (sunbursts), pill-shaped clouds, dots, stars
|
||||
- People as simple geometric figures with minimal facial detail
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Maintain consistent outline thickness throughout
|
||||
- Use soft, vintage color palette
|
||||
- Simplify all objects to basic geometric shapes
|
||||
- Create depth through layering
|
||||
- Add playful decorative elements
|
||||
|
||||
### Don't
|
||||
|
||||
- Use gradients or realistic shading
|
||||
- Create photorealistic elements
|
||||
- Use thin or varying line weights
|
||||
- Include complex detailed illustrations
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Educational presentations, creative proposals, children's content, brand showcases, warm approachable topics, explainer content
|
||||
@@ -0,0 +1,68 @@
|
||||
# warm
|
||||
|
||||
Soft gradients and wellness aesthetic with approachable, friendly feeling
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Soft, calming gradients with rounded shapes. Wellness and lifestyle aesthetic that feels approachable and nurturing. Organic flowing forms balanced with clean typography. Warmth and comfort in every element.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Soft gradient from Warm Peach (#FDF4F0) to Soft Lavender (#F5F0FF)
|
||||
- Texture: Subtle organic shapes, soft blurred elements
|
||||
|
||||
## Typography
|
||||
|
||||
### Primary Font (Headlines)
|
||||
|
||||
Rounded sans-serif like Nunito, Poppins, or Quicksand. Medium to semi-bold weight. Friendly, approachable letterforms. Generous spacing.
|
||||
|
||||
### Secondary Font (Body)
|
||||
|
||||
Same family in regular weight, or complementary rounded sans-serif. Comfortable reading size. Warm, inviting tone.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background Start | Warm Peach | #FDF4F0 | Gradient start |
|
||||
| Background End | Soft Lavender | #F5F0FF | Gradient end |
|
||||
| Primary Text | Warm Charcoal | #3D3D3D | Headlines, body |
|
||||
| Secondary Text | Soft Gray | #6B6B6B | Supporting text |
|
||||
| Accent 1 | Coral | #FF8A80 | Primary highlights |
|
||||
| Accent 2 | Soft Teal | #80CBC4 | Balance, growth |
|
||||
| Accent 3 | Lavender | #B39DDB | Calm, premium |
|
||||
| Accent 4 | Soft Yellow | #FFE082 | Energy, joy |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Soft gradient backgrounds (avoid harsh transitions)
|
||||
- Organic blob shapes with blurred edges
|
||||
- Rounded rectangles and pill shapes
|
||||
- Soft drop shadows (large blur, low opacity)
|
||||
- Simple line illustrations with rounded endpoints
|
||||
- Abstract organic patterns
|
||||
- Floating elements with gentle motion implied
|
||||
- Layered translucent shapes
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Use soft, gentle color transitions
|
||||
- Keep all corners rounded
|
||||
- Create sense of calm and space
|
||||
- Use organic, flowing shapes
|
||||
- Layer elements with transparency
|
||||
|
||||
### Don't
|
||||
|
||||
- Use sharp corners or harsh lines
|
||||
- Create high-contrast jarring combinations
|
||||
- Use dark or aggressive colors
|
||||
- Add busy or complex illustrations
|
||||
- Add slide numbers, footers, or logos
|
||||
|
||||
## Best For
|
||||
|
||||
Wellness content, personal development, lifestyle brands, health and fitness, mindfulness, self-care, coaching, therapy, HR presentations
|
||||
@@ -0,0 +1,116 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs";
|
||||
import { join, basename } from "path";
|
||||
import { PDFDocument, rgb } from "pdf-lib";
|
||||
|
||||
interface SlideInfo {
|
||||
filename: string;
|
||||
path: string;
|
||||
index: number;
|
||||
promptPath?: string;
|
||||
}
|
||||
|
||||
function parseArgs(): { dir: string; output?: string } {
|
||||
const args = process.argv.slice(2);
|
||||
let dir = "";
|
||||
let output: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--output" || args[i] === "-o") {
|
||||
output = args[++i];
|
||||
} else if (!args[i].startsWith("-")) {
|
||||
dir = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
console.error("Usage: bun merge-to-pdf.ts <slide-deck-dir> [--output filename.pdf]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { dir, output };
|
||||
}
|
||||
|
||||
function findSlideImages(dir: string): SlideInfo[] {
|
||||
if (!existsSync(dir)) {
|
||||
console.error(`Directory not found: ${dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(dir);
|
||||
const slidePattern = /^(\d+)-slide-.*\.(png|jpg|jpeg)$/i;
|
||||
const promptsDir = join(dir, "prompts");
|
||||
const hasPrompts = existsSync(promptsDir);
|
||||
|
||||
const slides: SlideInfo[] = files
|
||||
.filter((f) => slidePattern.test(f))
|
||||
.map((f) => {
|
||||
const match = f.match(slidePattern);
|
||||
const baseName = f.replace(/\.(png|jpg|jpeg)$/i, "");
|
||||
const promptPath = hasPrompts ? join(promptsDir, `${baseName}.md`) : undefined;
|
||||
|
||||
return {
|
||||
filename: f,
|
||||
path: join(dir, f),
|
||||
index: parseInt(match![1], 10),
|
||||
promptPath: promptPath && existsSync(promptPath) ? promptPath : undefined,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
if (slides.length === 0) {
|
||||
console.error(`No slide images found in: ${dir}`);
|
||||
console.error("Expected format: 01-slide-*.png, 02-slide-*.png, etc.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return slides;
|
||||
}
|
||||
|
||||
async function createPdf(slides: SlideInfo[], outputPath: string) {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
pdfDoc.setAuthor("baoyu-slide-deck");
|
||||
pdfDoc.setSubject("Generated Slide Deck");
|
||||
|
||||
for (const slide of slides) {
|
||||
const imageData = readFileSync(slide.path);
|
||||
const ext = slide.filename.toLowerCase();
|
||||
const image = ext.endsWith(".png")
|
||||
? await pdfDoc.embedPng(imageData)
|
||||
: await pdfDoc.embedJpg(imageData);
|
||||
|
||||
const { width, height } = image;
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
|
||||
page.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
console.log(`Added: ${slide.filename}${slide.promptPath ? " (prompt available)" : ""}`);
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
await Bun.write(outputPath, pdfBytes);
|
||||
|
||||
console.log(`\nCreated: ${outputPath}`);
|
||||
console.log(`Total pages: ${slides.length}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { dir, output } = parseArgs();
|
||||
const slides = findSlideImages(dir);
|
||||
|
||||
const dirName = basename(dir) === "slide-deck" ? basename(join(dir, "..")) : basename(dir);
|
||||
const outputPath = output || join(dir, `${dirName}.pdf`);
|
||||
|
||||
console.log(`Found ${slides.length} slides in: ${dir}\n`);
|
||||
|
||||
await createPdf(slides, outputPath);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs";
|
||||
import { join, basename, extname } from "path";
|
||||
import PptxGenJS from "pptxgenjs";
|
||||
|
||||
interface SlideInfo {
|
||||
filename: string;
|
||||
path: string;
|
||||
index: number;
|
||||
promptPath?: string;
|
||||
}
|
||||
|
||||
function parseArgs(): { dir: string; output?: string } {
|
||||
const args = process.argv.slice(2);
|
||||
let dir = "";
|
||||
let output: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--output" || args[i] === "-o") {
|
||||
output = args[++i];
|
||||
} else if (!args[i].startsWith("-")) {
|
||||
dir = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
console.error("Usage: bun merge-to-pptx.ts <slide-deck-dir> [--output filename.pptx]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { dir, output };
|
||||
}
|
||||
|
||||
function findSlideImages(dir: string): SlideInfo[] {
|
||||
if (!existsSync(dir)) {
|
||||
console.error(`Directory not found: ${dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(dir);
|
||||
const slidePattern = /^(\d+)-slide-.*\.(png|jpg|jpeg)$/i;
|
||||
const promptsDir = join(dir, "prompts");
|
||||
const hasPrompts = existsSync(promptsDir);
|
||||
|
||||
const slides: SlideInfo[] = files
|
||||
.filter((f) => slidePattern.test(f))
|
||||
.map((f) => {
|
||||
const match = f.match(slidePattern);
|
||||
const baseName = f.replace(/\.(png|jpg|jpeg)$/i, "");
|
||||
const promptPath = hasPrompts ? join(promptsDir, `${baseName}.md`) : undefined;
|
||||
|
||||
return {
|
||||
filename: f,
|
||||
path: join(dir, f),
|
||||
index: parseInt(match![1], 10),
|
||||
promptPath: promptPath && existsSync(promptPath) ? promptPath : undefined,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
if (slides.length === 0) {
|
||||
console.error(`No slide images found in: ${dir}`);
|
||||
console.error("Expected format: 01-slide-*.png, 02-slide-*.png, etc.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return slides;
|
||||
}
|
||||
|
||||
function findBasePrompt(): string | undefined {
|
||||
const scriptDir = import.meta.dir;
|
||||
const basePromptPath = join(scriptDir, "..", "references", "base-prompt.md");
|
||||
if (existsSync(basePromptPath)) {
|
||||
return readFileSync(basePromptPath, "utf-8");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function createPptx(slides: SlideInfo[], outputPath: string) {
|
||||
const pptx = new PptxGenJS();
|
||||
|
||||
pptx.layout = "LAYOUT_16x9";
|
||||
pptx.author = "baoyu-slide-deck";
|
||||
pptx.subject = "Generated Slide Deck";
|
||||
|
||||
const basePrompt = findBasePrompt();
|
||||
let notesCount = 0;
|
||||
|
||||
for (const slide of slides) {
|
||||
const s = pptx.addSlide();
|
||||
const imageData = readFileSync(slide.path);
|
||||
const base64 = imageData.toString("base64");
|
||||
const ext = extname(slide.filename).toLowerCase().replace(".", "");
|
||||
const mimeType = ext === "png" ? "image/png" : "image/jpeg";
|
||||
|
||||
s.addImage({
|
||||
data: `data:${mimeType};base64,${base64}`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: "100%",
|
||||
h: "100%",
|
||||
sizing: { type: "cover", w: "100%", h: "100%" },
|
||||
});
|
||||
|
||||
if (slide.promptPath) {
|
||||
const slidePrompt = readFileSync(slide.promptPath, "utf-8");
|
||||
const fullNotes = basePrompt ? `${basePrompt}\n\n---\n\n${slidePrompt}` : slidePrompt;
|
||||
s.addNotes(fullNotes);
|
||||
notesCount++;
|
||||
}
|
||||
|
||||
console.log(`Added: ${slide.filename}${slide.promptPath ? " (with notes)" : ""}`);
|
||||
}
|
||||
|
||||
await pptx.writeFile({ fileName: outputPath });
|
||||
console.log(`\nCreated: ${outputPath}`);
|
||||
console.log(`Total slides: ${slides.length}`);
|
||||
if (notesCount > 0) {
|
||||
console.log(`Slides with notes: ${notesCount}${basePrompt ? " (includes base prompt)" : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { dir, output } = parseArgs();
|
||||
const slides = findSlideImages(dir);
|
||||
|
||||
const dirName = basename(dir) === "slide-deck" ? basename(join(dir, "..")) : basename(dir);
|
||||
const outputPath = output || join(dir, `${dirName}.pptx`);
|
||||
|
||||
console.log(`Found ${slides.length} slides in: ${dir}\n`);
|
||||
|
||||
await createPptx(slides, outputPath);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+168
-398
@@ -49,294 +49,175 @@ Style × Layout can be freely combined. Example: `--style notion --layout dense`
|
||||
|
||||
## Style Gallery
|
||||
|
||||
### 1. `cute` (Default)
|
||||
Sweet, adorable, girly - classic Xiaohongshu aesthetic
|
||||
- **Colors**: Pink, peach, mint, lavender, cream background
|
||||
- **Elements**: Cute stickers, emoji icons, hearts, stars, sparkles
|
||||
- **Best for**: Lifestyle, beauty, fashion, daily tips
|
||||
| Style | Description |
|
||||
|-------|-------------|
|
||||
| `cute` (Default) | Sweet, adorable, girly - classic Xiaohongshu aesthetic |
|
||||
| `fresh` | Clean, refreshing, natural |
|
||||
| `tech` | Modern, smart, digital |
|
||||
| `warm` | Cozy, friendly, approachable |
|
||||
| `bold` | High impact, attention-grabbing |
|
||||
| `minimal` | Ultra-clean, sophisticated |
|
||||
| `retro` | Vintage, nostalgic, trendy |
|
||||
| `pop` | Vibrant, energetic, eye-catching |
|
||||
| `notion` | Minimalist hand-drawn line art, intellectual |
|
||||
|
||||
### 2. `fresh`
|
||||
Clean, refreshing, natural
|
||||
- **Colors**: Mint green, sky blue, white, light yellow
|
||||
- **Elements**: Plant icons, clouds, simple shapes, breathing room
|
||||
- **Best for**: Health, wellness, minimalist lifestyle, self-care
|
||||
|
||||
### 3. `tech`
|
||||
Modern, smart, digital
|
||||
- **Colors**: Deep blue, purple, cyan, dark backgrounds with neon accents
|
||||
- **Elements**: Geometric shapes, data icons, circuit patterns, glowing effects
|
||||
- **Best for**: Tech tutorials, AI content, digital tools, productivity
|
||||
|
||||
### 4. `warm`
|
||||
Cozy, friendly, approachable
|
||||
- **Colors**: Warm orange, golden yellow, brown, cream
|
||||
- **Elements**: Sun motifs, coffee cups, cozy illustrations, warm lighting
|
||||
- **Best for**: Personal stories, life lessons, emotional content
|
||||
|
||||
### 5. `bold`
|
||||
High impact, attention-grabbing
|
||||
- **Colors**: Red, orange, black, yellow accents
|
||||
- **Elements**: Strong typography, exclamation marks, arrows, contrast
|
||||
- **Best for**: Important tips, warnings, must-know content
|
||||
|
||||
### 6. `minimal`
|
||||
Ultra-clean, sophisticated
|
||||
- **Colors**: Black, white, single accent color
|
||||
- **Elements**: Maximum whitespace, simple icons, clean lines
|
||||
- **Best for**: Professional content, serious topics, elegant presentations
|
||||
|
||||
### 7. `retro`
|
||||
Vintage, nostalgic, trendy
|
||||
- **Colors**: Muted pastels, sepia, faded tones
|
||||
- **Elements**: Vintage badges, halftone dots, classic typography
|
||||
- **Best for**: Throwback content, classic tips, timeless advice
|
||||
|
||||
### 8. `pop`
|
||||
Vibrant, energetic, eye-catching
|
||||
- **Colors**: Bright primary colors, neon accents, white
|
||||
- **Elements**: Bold shapes, comic-style elements, dynamic compositions
|
||||
- **Best for**: Exciting announcements, fun facts, engaging tutorials
|
||||
|
||||
### 9. `notion`
|
||||
Minimalist hand-drawn line art, intellectual
|
||||
- **Colors**: Black outlines, white background, 1-2 pastel accents
|
||||
- **Elements**: Simple line doodles, geometric shapes, hand-drawn wobble, maximum whitespace
|
||||
- **Best for**: Knowledge sharing, concept explanations, SaaS content, productivity tips
|
||||
Detailed style definitions: `references/styles/<style>.md`
|
||||
|
||||
## Layout Gallery
|
||||
|
||||
### 1. `sparse` (Default)
|
||||
Minimal information, maximum impact
|
||||
- **Density**: 1-2 key points per image
|
||||
- **Whitespace**: 60-70% of canvas
|
||||
- **Structure**: Single focal point, one core message
|
||||
- **Best for**: Covers, quotes, impactful statements, emotional content
|
||||
| Layout | Description |
|
||||
|--------|-------------|
|
||||
| `sparse` (Default) | Minimal information, maximum impact (1-2 points) |
|
||||
| `balanced` | Standard content layout (3-4 points) |
|
||||
| `dense` | High information density, knowledge card style (5-8 points) |
|
||||
| `list` | Enumeration and ranking format (4-7 items) |
|
||||
| `comparison` | Side-by-side contrast layout |
|
||||
| `flow` | Process and timeline layout (3-6 steps) |
|
||||
|
||||
### 2. `balanced`
|
||||
Standard content layout
|
||||
- **Density**: 3-4 key points per image
|
||||
- **Whitespace**: 40-50% of canvas
|
||||
- **Structure**: Title + 3-4 bullet points or sections
|
||||
- **Best for**: Regular content pages, tutorials, explanations
|
||||
Detailed layout definitions: `references/layouts/<layout>.md`
|
||||
|
||||
### 3. `dense`
|
||||
High information density, knowledge card style
|
||||
- **Density**: 5-8 key points per image
|
||||
- **Whitespace**: 20-30% of canvas
|
||||
- **Structure**: Multiple sections, structured grid, more text
|
||||
- **Best for**: Summary cards, cheat sheets, comprehensive guides, 干货总结
|
||||
## Auto Selection
|
||||
|
||||
### 4. `list`
|
||||
Enumeration and ranking format
|
||||
- **Density**: 4-7 items
|
||||
- **Whitespace**: 30-40% of canvas
|
||||
- **Structure**: Numbered or bulleted vertical list, consistent item format
|
||||
- **Best for**: Top N lists, checklists, step-by-step guides, rankings
|
||||
| Content Signals | Style | Layout |
|
||||
|-----------------|-------|--------|
|
||||
| Beauty, fashion, cute, girl, pink | `cute` | sparse/balanced |
|
||||
| Health, nature, clean, fresh, organic | `fresh` | balanced/flow |
|
||||
| Tech, AI, code, digital, app, tool | `tech` | dense/list |
|
||||
| Life, story, emotion, feeling, warm | `warm` | balanced |
|
||||
| Warning, important, must, critical | `bold` | list/comparison |
|
||||
| Professional, business, elegant, simple | `minimal` | sparse/balanced |
|
||||
| Classic, vintage, old, traditional | `retro` | balanced |
|
||||
| Fun, exciting, wow, amazing | `pop` | sparse/list |
|
||||
| Knowledge, concept, productivity, SaaS | `notion` | dense/list |
|
||||
|
||||
### 5. `comparison`
|
||||
Side-by-side contrast layout
|
||||
- **Density**: 2 main sections with 2-4 points each
|
||||
- **Whitespace**: 30-40% of canvas
|
||||
- **Structure**: Left vs Right, Before/After, Pros/Cons
|
||||
- **Best for**: Comparisons, transformations, decision helpers, 对比图
|
||||
|
||||
### 6. `flow`
|
||||
Process and timeline layout
|
||||
- **Density**: 3-6 steps/stages
|
||||
- **Whitespace**: 30-40% of canvas
|
||||
- **Structure**: Connected nodes with arrows, sequential flow
|
||||
- **Best for**: Processes, timelines, cause-effect chains, workflows
|
||||
|
||||
## Auto Style Selection
|
||||
|
||||
When no `--style` is specified, analyze content to select:
|
||||
|
||||
| Content Signals | Selected Style |
|
||||
|----------------|----------------|
|
||||
| Beauty, fashion, cute, girl, pink | `cute` |
|
||||
| Health, nature, clean, fresh, organic | `fresh` |
|
||||
| Tech, AI, code, digital, app, tool | `tech` |
|
||||
| Life, story, emotion, feeling, warm | `warm` |
|
||||
| Warning, important, must, critical | `bold` |
|
||||
| Professional, business, elegant, simple | `minimal` |
|
||||
| Classic, vintage, old, traditional | `retro` |
|
||||
| Fun, exciting, wow, amazing | `pop` |
|
||||
| Knowledge, concept, productivity, SaaS, notion | `notion` |
|
||||
|
||||
## Auto Layout Selection
|
||||
|
||||
When no `--layout` is specified, analyze content structure to select:
|
||||
|
||||
| Content Signals | Selected Layout |
|
||||
|----------------|-----------------|
|
||||
| Single quote, one key point, cover | `sparse` |
|
||||
| 3-4 points, explanation, tutorial | `balanced` |
|
||||
| 5+ points, summary, cheat sheet, 干货 | `dense` |
|
||||
| Numbered items, top N, checklist, steps | `list` |
|
||||
| vs, compare, before/after, pros/cons | `comparison` |
|
||||
| Process, flow, timeline, steps with order | `flow` |
|
||||
|
||||
**Layout by Position**:
|
||||
| Position | Recommended Layout |
|
||||
|----------|-------------------|
|
||||
| Cover | `sparse` |
|
||||
| Content | `balanced` or content-appropriate |
|
||||
| Ending | `sparse` or `balanced` |
|
||||
|
||||
## File Management
|
||||
|
||||
### With Article Path
|
||||
|
||||
Save to `xhs-images/` subdirectory in the same folder as the article:
|
||||
## File Structure
|
||||
|
||||
```
|
||||
posts/ai-future/
|
||||
├── article.md
|
||||
└── xhs-images/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── 01-cover.md
|
||||
│ ├── 02-content-1.md
|
||||
│ └── ...
|
||||
├── 01-cover.png
|
||||
├── 02-content-1.png
|
||||
└── 03-ending.png
|
||||
[target]/
|
||||
├── source.md # Source content (if pasted)
|
||||
├── analysis.md # Deep analysis results
|
||||
├── outline-style-[slug].md # Variant A (e.g., outline-style-tech.md)
|
||||
├── outline-style-[slug].md # Variant B (e.g., outline-style-notion.md)
|
||||
├── outline-style-[slug].md # Variant C (e.g., outline-style-minimal.md)
|
||||
├── outline.md # Final selected
|
||||
├── prompts/
|
||||
│ ├── 01-cover-[slug].md
|
||||
│ ├── 02-content-[slug].md
|
||||
│ └── ...
|
||||
├── 01-cover-[slug].png
|
||||
├── 02-content-[slug].png
|
||||
└── NN-ending-[slug].png
|
||||
```
|
||||
|
||||
### Without Article Path
|
||||
**Target directory**:
|
||||
- With source path: `[source-dir]/[source-name-no-ext]/xhs-images/`
|
||||
- Example: `/tests-data/article.md` → `/tests-data/article/xhs-images/`
|
||||
- Without source: `./xhs-images/[topic-slug]/`
|
||||
|
||||
Save to `xhs-outputs/YYYY-MM-DD/[topic-slug]/`:
|
||||
|
||||
```
|
||||
xhs-outputs/
|
||||
└── 2026-01-13/
|
||||
└── ai-agent-guide/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── 01-cover.md
|
||||
│ └── ...
|
||||
├── 01-cover.png
|
||||
└── 02-ending.png
|
||||
```
|
||||
**Directory backup**:
|
||||
- If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content & Select Style/Layout
|
||||
### Step 1: Analyze Content → `analysis.md`
|
||||
|
||||
1. Read content
|
||||
2. If `--style` specified, use that style; otherwise auto-select
|
||||
3. If `--layout` specified, use that layout; otherwise auto-select per image
|
||||
4. Determine image count based on content complexity:
|
||||
Read source content, save it if needed, and perform deep analysis.
|
||||
|
||||
| Content Type | Image Count |
|
||||
|-------------|-------------|
|
||||
| Simple opinion / single topic | 2-3 |
|
||||
| Medium complexity / tutorial | 4-6 |
|
||||
| Deep dive / multi-dimensional | 7-10 |
|
||||
**Actions**:
|
||||
1. **Save source content** (if not already a file):
|
||||
- If user provides a file path: use as-is
|
||||
- If user pastes content: save to `source.md` in target directory
|
||||
2. Read source content
|
||||
3. **Deep analysis** following `references/analysis-framework.md`:
|
||||
- Content type classification (种草/干货/测评/教程/避坑...)
|
||||
- Hook analysis (爆款标题潜力)
|
||||
- Target audience identification
|
||||
- Engagement potential (收藏/分享/评论)
|
||||
- Visual opportunity mapping
|
||||
- Swipe flow design
|
||||
4. Detect source language
|
||||
5. Determine recommended image count (2-10)
|
||||
6. Select 3 style+layout combinations
|
||||
7. **Save to `analysis.md`**
|
||||
|
||||
**Note**: Layout can vary per image in a series. Cover typically uses `sparse`, content pages use `balanced`/`dense`/`list` as appropriate.
|
||||
### Step 2: Generate 3 Outline Variants
|
||||
|
||||
### Step 2: Generate Outline
|
||||
Based on analysis, create three distinct style variants.
|
||||
|
||||
Plan for each image with style and layout specifications:
|
||||
**For each variant**:
|
||||
1. **Generate outline** (`outline-style-[slug].md`):
|
||||
- YAML front matter with style, layout, image_count
|
||||
- Cover design with hook
|
||||
- Each image: layout, core message, text content, visual concept
|
||||
- **Written in user's preferred language**
|
||||
- Reference: `references/outline-template.md`
|
||||
|
||||
```markdown
|
||||
# Xiaohongshu Infographic Series Outline
|
||||
| Variant | Selection Logic | Example Filename |
|
||||
|---------|-----------------|------------------|
|
||||
| A | Primary recommendation | `outline-style-tech.md` |
|
||||
| B | Alternative style | `outline-style-notion.md` |
|
||||
| C | Different audience/mood | `outline-style-minimal.md` |
|
||||
|
||||
**Topic**: [topic description]
|
||||
**Style**: [selected style]
|
||||
**Default Layout**: [selected layout or "varies"]
|
||||
**Image Count**: N
|
||||
**Generated**: YYYY-MM-DD HH:mm
|
||||
**All variants are preserved after selection for reference.**
|
||||
|
||||
---
|
||||
### Step 3: User Confirms All Options
|
||||
|
||||
## Image 1 of N
|
||||
**IMPORTANT**: Present ALL options in a single confirmation step using AskUserQuestion. Do NOT interrupt workflow with multiple separate confirmations.
|
||||
|
||||
**Position**: Cover
|
||||
**Layout**: sparse
|
||||
**Core Message**: [one-liner]
|
||||
**Filename**: 01-cover.png
|
||||
**Determine which questions to ask**:
|
||||
|
||||
**Text Content**:
|
||||
- Title: xxx
|
||||
- Subtitle: xxx
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Style variant | Always (required) |
|
||||
| Default layout | Only if user might want to override |
|
||||
| Language | Only if `source_language ≠ user_language` |
|
||||
|
||||
**Visual Concept**: [style + layout appropriate description]
|
||||
**Language handling**:
|
||||
- If source language = user language: Just inform user (e.g., "Images will be in Chinese")
|
||||
- If different: Ask which language to use
|
||||
|
||||
---
|
||||
**AskUserQuestion format**:
|
||||
|
||||
## Image 2 of N
|
||||
```
|
||||
Question 1 (Style): Which style variant?
|
||||
- A: tech + dense (Recommended) - 专业科技感,适合干货
|
||||
- B: notion + list - 清爽知识卡片
|
||||
- C: minimal + balanced - 简约高端风格
|
||||
- Custom: 自定义风格描述
|
||||
|
||||
**Position**: Content
|
||||
**Layout**: [balanced/dense/list/comparison/flow]
|
||||
**Core Message**: [one-liner]
|
||||
**Filename**: 02-xxx.png
|
||||
Question 2 (Layout) - only if relevant:
|
||||
- Keep variant default (Recommended)
|
||||
- sparse / balanced / dense / list / comparison / flow
|
||||
|
||||
**Text Content**:
|
||||
- Title: xxx
|
||||
- Points: [list based on layout density]
|
||||
|
||||
**Visual Concept**: [description matching style + layout]
|
||||
|
||||
---
|
||||
...
|
||||
Question 3 (Language) - only if mismatch:
|
||||
- 中文 (匹配原文)
|
||||
- English (your preference)
|
||||
```
|
||||
|
||||
### Step 3: Save Outline
|
||||
**After confirmation**:
|
||||
1. Copy selected `outline-style-[slug].md` → `outline.md`
|
||||
2. Update YAML front matter with confirmed options
|
||||
3. If custom style: regenerate outline with that style
|
||||
4. User may edit `outline.md` directly for fine-tuning
|
||||
|
||||
Save outline as `outline.md`.
|
||||
### Step 4: Generate Images
|
||||
|
||||
### Step 4: Generate Images One by One
|
||||
With confirmed outline + style + layout:
|
||||
|
||||
For each image, create a prompt file with style and layout specifications.
|
||||
|
||||
**Prompt Format**:
|
||||
|
||||
```markdown
|
||||
Infographic theme: [topic]
|
||||
Style: [style name]
|
||||
Layout: [layout name]
|
||||
Position: [cover/content/ending]
|
||||
|
||||
Visual composition:
|
||||
- Main visual: [style-appropriate description]
|
||||
- Arrangement: [layout-specific structure]
|
||||
- Decorative elements: [style-specific decorations]
|
||||
|
||||
Color scheme:
|
||||
- Primary: [style primary color]
|
||||
- Background: [style background color]
|
||||
- Accent: [style accent color]
|
||||
|
||||
Text content:
|
||||
- Title: 「xxx」(large, prominent)
|
||||
- Key points: [based on layout density]
|
||||
|
||||
Layout instructions: [layout-specific guidance]
|
||||
Style notes: [style-specific characteristics]
|
||||
```
|
||||
|
||||
**Layout-Specific Instructions**:
|
||||
|
||||
| Layout | Arrangement Instructions |
|
||||
|--------|-------------------------|
|
||||
| `sparse` | Single focal point centered, 1-2 text elements, maximum breathing room |
|
||||
| `balanced` | Title at top, 3-4 points in clear sections, moderate spacing |
|
||||
| `dense` | Grid or multi-section layout, 5-8 points, compact but organized |
|
||||
| `list` | Vertical numbered/bulleted list, consistent item spacing, clear hierarchy |
|
||||
| `comparison` | Two-column split, clear divider, mirrored structure left/right |
|
||||
| `flow` | Horizontal or vertical flow with arrows, connected nodes/steps |
|
||||
**For each image (cover + content + ending)**:
|
||||
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
|
||||
2. Generate image using confirmed style and layout
|
||||
3. Report progress after each generation
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
2. If multiple skills available, ask user to choose
|
||||
- Check available image generation skills
|
||||
- If multiple skills available, ask user preference
|
||||
|
||||
**Generation Flow**:
|
||||
1. Call selected image generation skill with prompt file and output path
|
||||
2. Confirm generation success
|
||||
3. Report progress: "Generated X/N"
|
||||
4. Continue to next
|
||||
**Session Management**:
|
||||
If image generation skill supports `--sessionId`:
|
||||
1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}`
|
||||
2. Use same session ID for all images
|
||||
3. Ensures visual consistency across generated images
|
||||
|
||||
### Step 5: Completion Report
|
||||
|
||||
@@ -349,166 +230,48 @@ Layout: [layout name or "varies"]
|
||||
Location: [directory path]
|
||||
Images: N total
|
||||
|
||||
- 01-cover.png ✓ Cover (sparse)
|
||||
- 02-content-1.png ✓ Content (balanced)
|
||||
- 03-content-2.png ✓ Content (dense)
|
||||
- 04-ending.png ✓ Ending (sparse)
|
||||
✓ analysis.md
|
||||
✓ outline-style-tech.md
|
||||
✓ outline-style-notion.md
|
||||
✓ outline-style-minimal.md
|
||||
✓ outline.md (selected: tech + dense)
|
||||
|
||||
Outline: outline.md
|
||||
Files:
|
||||
- 01-cover-[slug].png ✓ Cover (sparse)
|
||||
- 02-content-[slug].png ✓ Content (balanced)
|
||||
- 03-content-[slug].png ✓ Content (dense)
|
||||
- 04-ending-[slug].png ✓ Ending (sparse)
|
||||
```
|
||||
|
||||
## Style Reference Details
|
||||
## Image Modification
|
||||
|
||||
### cute
|
||||
```
|
||||
Colors: Pink (#FED7E2), peach (#FEEBC8), mint (#C6F6D5), lavender (#E9D8FD)
|
||||
Background: Cream (#FFFAF0), soft pink (#FFF5F7)
|
||||
Accents: Hot pink, coral
|
||||
Elements: Hearts, stars, sparkles, cute faces, ribbon decorations, sticker-style
|
||||
Typography: Rounded, bubbly hand lettering
|
||||
```
|
||||
### Edit Single Image
|
||||
|
||||
### fresh
|
||||
```
|
||||
Colors: Mint green (#9AE6B4), sky blue (#90CDF4), light yellow (#FAF089)
|
||||
Background: Pure white (#FFFFFF), soft mint (#F0FFF4)
|
||||
Accents: Leaf green, water blue
|
||||
Elements: Plant leaves, clouds, water drops, simple geometric shapes
|
||||
Typography: Clean, light hand lettering with breathing room
|
||||
```
|
||||
1. Identify image to edit (e.g., `03-content-chatgpt.png`)
|
||||
2. Update prompt in `prompts/03-content-chatgpt.md` if needed
|
||||
3. Regenerate image using same session ID
|
||||
|
||||
### tech
|
||||
```
|
||||
Colors: Deep blue (#1A365D), purple (#6B46C1), cyan (#00D4FF)
|
||||
Background: Dark gray (#1A202C), near-black (#0D1117)
|
||||
Accents: Neon green (#00FF88), electric blue
|
||||
Elements: Circuit patterns, data icons, geometric grids, glowing effects
|
||||
Typography: Monospace-style hand lettering, subtle glow
|
||||
```
|
||||
### Add New Image
|
||||
|
||||
### warm
|
||||
```
|
||||
Colors: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
|
||||
Background: Cream (#FFFAF0), soft peach (#FED7AA)
|
||||
Accents: Deep brown (#744210), soft red
|
||||
Elements: Sun rays, coffee cups, cozy items, warm lighting effects
|
||||
Typography: Friendly, rounded hand lettering
|
||||
```
|
||||
1. Specify insertion position (e.g., after image 3)
|
||||
2. Create new prompt with appropriate slug
|
||||
3. Generate new image
|
||||
4. **Renumber files**: All subsequent images increment NN by 1
|
||||
5. Update `outline.md` with new image entry
|
||||
|
||||
### bold
|
||||
```
|
||||
Colors: Vibrant red (#E53E3E), orange (#DD6B20), yellow (#F6E05E)
|
||||
Background: Deep black (#000000), dark charcoal
|
||||
Accents: White, neon yellow
|
||||
Elements: Exclamation marks, arrows, warning icons, strong shapes
|
||||
Typography: Bold, impactful hand lettering with shadows
|
||||
```
|
||||
### Delete Image
|
||||
|
||||
### minimal
|
||||
```
|
||||
Colors: Black (#000000), white (#FFFFFF)
|
||||
Background: Off-white (#FAFAFA), pure white
|
||||
Accents: Single color (content-derived - blue, green, or coral)
|
||||
Elements: Single focal point, thin lines, maximum whitespace
|
||||
Typography: Clean, simple hand lettering
|
||||
```
|
||||
|
||||
### retro
|
||||
```
|
||||
Colors: Muted orange, dusty pink (#FED7E2 at 70%), faded teal
|
||||
Background: Aged paper (#F5E6D3), sepia tones
|
||||
Accents: Faded red, vintage gold
|
||||
Elements: Halftone dots, vintage badges, classic icons, tape effects
|
||||
Typography: Vintage-style hand lettering, classic feel
|
||||
```
|
||||
|
||||
### pop
|
||||
```
|
||||
Colors: Bright red (#F56565), yellow (#ECC94B), blue (#4299E1), green (#48BB78)
|
||||
Background: White (#FFFFFF), light gray
|
||||
Accents: Neon pink, electric purple
|
||||
Elements: Bold shapes, speech bubbles, comic-style effects, starburst
|
||||
Typography: Dynamic, energetic hand lettering with outlines
|
||||
```
|
||||
|
||||
### notion
|
||||
```
|
||||
Colors: Black (#1A1A1A), dark gray (#4A4A4A)
|
||||
Background: Pure white (#FFFFFF), off-white (#FAFAFA)
|
||||
Accents: Pastel blue (#A8D4F0), pastel yellow (#F9E79F), pastel pink (#FADBD8)
|
||||
Elements: Simple line doodles, hand-drawn wobble effect, geometric shapes, stick figures, maximum whitespace
|
||||
Typography: Clean hand-drawn lettering, simple sans-serif labels
|
||||
```
|
||||
|
||||
## Layout Reference Details
|
||||
|
||||
### sparse
|
||||
```
|
||||
Information Density: Very Low (1-2 points)
|
||||
Whitespace: 60-70%
|
||||
Structure: Single centered focal point
|
||||
Text Elements: Title only, or title + one subtitle/tagline
|
||||
Visual Balance: Centered, symmetrical, breathing room on all sides
|
||||
Best Pairing: Any style, especially effective with bold, minimal, notion
|
||||
```
|
||||
|
||||
### balanced
|
||||
```
|
||||
Information Density: Medium (3-4 points)
|
||||
Whitespace: 40-50%
|
||||
Structure: Title at top, content sections below
|
||||
Text Elements: Title + 3-4 bullet points or key messages
|
||||
Visual Balance: Top-weighted title, evenly distributed content below
|
||||
Best Pairing: All styles work well
|
||||
```
|
||||
|
||||
### dense
|
||||
```
|
||||
Information Density: High (5-8 points)
|
||||
Whitespace: 20-30%
|
||||
Structure: Multi-section grid or stacked blocks
|
||||
Text Elements: Title + multiple sections with headers + numerous points
|
||||
Visual Balance: Organized chaos, clear section boundaries, compact spacing
|
||||
Best Pairing: tech, notion, minimal (clean styles prevent visual overload)
|
||||
```
|
||||
|
||||
### list
|
||||
```
|
||||
Information Density: Medium-High (4-7 items)
|
||||
Whitespace: 30-40%
|
||||
Structure: Vertical enumeration with numbers or bullets
|
||||
Text Elements: Title + numbered/bulleted items, consistent format per item
|
||||
Visual Balance: Left-aligned list, clear number/bullet hierarchy
|
||||
Best Pairing: All styles, especially cute (checklist), bold (rankings)
|
||||
```
|
||||
|
||||
### comparison
|
||||
```
|
||||
Information Density: Medium (2×2-4 points)
|
||||
Whitespace: 30-40%
|
||||
Structure: Two-column split with center divider
|
||||
Text Elements: Title + left label + right label + mirrored points
|
||||
Visual Balance: Symmetrical left/right, clear visual contrast
|
||||
Best Pairing: bold (dramatic contrast), tech (data comparison), warm (before/after stories)
|
||||
```
|
||||
|
||||
### flow
|
||||
```
|
||||
Information Density: Medium (3-6 steps)
|
||||
Whitespace: 30-40%
|
||||
Structure: Connected nodes with directional arrows
|
||||
Text Elements: Title + step labels + optional descriptions per step
|
||||
Visual Balance: Directional flow (top→bottom or left→right), clear progression
|
||||
Best Pairing: tech (process diagrams), notion (simple flows), fresh (organic flows)
|
||||
```
|
||||
1. Remove image file and prompt file
|
||||
2. **Renumber files**: All subsequent images decrement NN by 1
|
||||
3. Update `outline.md` to remove image entry
|
||||
|
||||
## Content Breakdown Principles
|
||||
|
||||
1. **Cover (Image 1)**: Strong visual impact, core title, attention hook → `sparse` layout
|
||||
2. **Content (Middle)**: Core points per image, density varies by content → `balanced`/`dense`/`list`/`comparison`/`flow`
|
||||
3. **Ending (Last)**: Summary / call-to-action / memorable quote → `sparse` or `balanced`
|
||||
1. **Cover (Image 1)**: Hook + visual impact → `sparse` layout
|
||||
2. **Content (Middle)**: Core value per image → `balanced`/`dense`/`list`/`comparison`/`flow`
|
||||
3. **Ending (Last)**: CTA / summary → `sparse` or `balanced`
|
||||
|
||||
**Style × Layout Matrix** (recommended combinations):
|
||||
**Style × Layout Matrix** (✓✓ = highly recommended, ✓ = works well):
|
||||
|
||||
| | sparse | balanced | dense | list | comparison | flow |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
@@ -522,12 +285,19 @@ Best Pairing: tech (process diagrams), notion (simple flows), fresh (organic flo
|
||||
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
|
||||
✓✓ = highly recommended, ✓ = works well
|
||||
## References
|
||||
|
||||
Detailed templates and guidelines in `references/` directory:
|
||||
- `analysis-framework.md` - XHS-specific content analysis
|
||||
- `outline-template.md` - Outline format and examples
|
||||
- `styles/<style>.md` - Detailed style definitions
|
||||
- `layouts/<layout>.md` - Detailed layout definitions
|
||||
- `base-prompt.md` - Base prompt template
|
||||
|
||||
## Notes
|
||||
|
||||
- Image generation typically takes 10-30 seconds per image
|
||||
- Auto-retry once on generation failure
|
||||
- Use cartoon alternatives for sensitive public figures
|
||||
- Output language matches input content language
|
||||
- Maintain selected style consistency across all images in series
|
||||
- All prompts and text use confirmed language preference
|
||||
- Maintain style consistency across all images in series
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# Xiaohongshu Content Analysis Framework
|
||||
|
||||
Deep analysis framework tailored for Xiaohongshu's unique engagement patterns.
|
||||
|
||||
## Purpose
|
||||
|
||||
Before creating infographics, thoroughly analyze the source material to:
|
||||
- Maximize hook power and swipe motivation
|
||||
- Identify save-worthy and share-worthy elements
|
||||
- Plan the visual narrative arc
|
||||
- Match content to optimal style/layout
|
||||
|
||||
## Platform Characteristics
|
||||
|
||||
Unlike other platforms, Xiaohongshu content must prioritize:
|
||||
- **Hook Power**: First image decides 90% of engagement
|
||||
- **Swipe Motivation**: Each image must compel users to continue
|
||||
- **Save Value**: Content worth bookmarking for later
|
||||
- **Share Triggers**: Emotional resonance that drives sharing
|
||||
|
||||
## Analysis Dimensions
|
||||
|
||||
### 1. Content Type Classification
|
||||
|
||||
| Type | Characteristics | Best Style | Best Layout |
|
||||
|------|----------------|------------|-------------|
|
||||
| 种草/安利 | Product recommendation, benefits focus | cute/fresh | balanced/list |
|
||||
| 干货分享 | Knowledge, tips, how-to | notion/tech | dense/list |
|
||||
| 个人故事 | Personal experience, emotional | warm | balanced |
|
||||
| 测评对比 | Review, comparison, pros/cons | tech/bold | comparison |
|
||||
| 教程步骤 | Step-by-step guide | fresh/notion | flow/list |
|
||||
| 避坑指南 | Warnings, mistakes to avoid | bold | list/comparison |
|
||||
| 清单合集 | Collections, recommendations | cute/minimal | list/dense |
|
||||
|
||||
### 2. Hook Analysis (爆款标题潜力)
|
||||
|
||||
Evaluate title/hook potential using these patterns:
|
||||
|
||||
**Hook Types**:
|
||||
- **数字钩子**: "5个方法", "3分钟学会", "99%的人不知道"
|
||||
- **痛点钩子**: "踩过的坑", "后悔没早知道", "别再..."
|
||||
- **好奇钩子**: "原来...", "竟然...", "没想到..."
|
||||
- **利益钩子**: "省钱", "变美", "效率翻倍"
|
||||
- **身份钩子**: "打工人必看", "学生党", "新手妈妈"
|
||||
|
||||
**Rating Scale**:
|
||||
- ⭐⭐⭐⭐⭐ (5/5): Multiple strong hooks combined
|
||||
- ⭐⭐⭐⭐ (4/5): Clear hook with room for enhancement
|
||||
- ⭐⭐⭐ (3/5): Basic hook, needs strengthening
|
||||
- ⭐⭐ (2/5): Weak hook, requires significant improvement
|
||||
- ⭐ (1/5): No clear hook
|
||||
|
||||
### 3. Target Audience (用户画像)
|
||||
|
||||
| Audience | Interests | Preferred Style | Content Focus |
|
||||
|----------|-----------|-----------------|---------------|
|
||||
| 学生党 | 省钱、学习、校园 | cute/fresh | 平价、教程、学习方法 |
|
||||
| 打工人 | 效率、职场、减压 | minimal/tech | 工具、技巧、摸鱼 |
|
||||
| 宝妈 | 育儿、家居、省心 | warm/fresh | 实用、安全、经验 |
|
||||
| 精致女孩 | 美妆、穿搭、仪式感 | cute/retro | 好看、氛围、品质 |
|
||||
| 技术宅 | 工具、效率、极客 | tech/notion | 深度、专业、新奇 |
|
||||
| 美食爱好者 | 探店、食谱、测评 | warm/pop | 好吃、简单、颜值 |
|
||||
| 旅行达人 | 攻略、打卡、小众 | fresh/retro | 省钱、避坑、拍照 |
|
||||
|
||||
### 4. Engagement Potential
|
||||
|
||||
**Save Value (收藏价值)**:
|
||||
- Is it reference material? ✓ High save potential
|
||||
- Is it a checklist or list? ✓ High save potential
|
||||
- Is it a tutorial? ✓ High save potential
|
||||
- Is it time-sensitive news? ✗ Low save potential
|
||||
|
||||
**Share Triggers (分享冲动)**:
|
||||
- "我朋友也需要看这个" → High share potential
|
||||
- "这说的就是我" → Identity resonance
|
||||
- "太有用了必须分享" → Utility sharing
|
||||
- "笑死,给朋友看看" → Entertainment sharing
|
||||
|
||||
**Comment Inducement (评论诱导)**:
|
||||
- Open-ended questions: "你是哪种类型?"
|
||||
- Experience sharing: "评论区说说你的经历"
|
||||
- Debate triggers: "你觉得呢?"
|
||||
- Help requests: "有更好的推荐吗?"
|
||||
|
||||
**Interaction Design (互动设计)**:
|
||||
- Polls: "A还是B?"
|
||||
- Challenges: "你能做到几个?"
|
||||
- Tags: "@你那个需要的朋友"
|
||||
|
||||
### 5. Visual Opportunity Map
|
||||
|
||||
| Content Element | Visual Treatment | Example |
|
||||
|-----------------|------------------|---------|
|
||||
| 数据/统计 | Highlighted numbers, simple charts | "节省80%时间" 大字突出 |
|
||||
| 对比 | Before/after, side-by-side | 左右分屏对比图 |
|
||||
| 步骤 | Numbered flow, arrows | 1→2→3 流程图 |
|
||||
| 清单 | Checklist with icons | ✓/✗ 列表配图标 |
|
||||
| 情感 | Character expressions, scenes | 卡通人物表情包 |
|
||||
| 产品 | Product showcase, lifestyle | 产品实拍+使用场景 |
|
||||
| 引用 | Quote cards, speech bubbles | 金句卡片设计 |
|
||||
|
||||
### 6. Swipe Flow Design
|
||||
|
||||
Plan the narrative arc across images:
|
||||
|
||||
| Position | Purpose | Hook Strategy |
|
||||
|----------|---------|---------------|
|
||||
| **Cover (封面)** | Stop scrolling | 最强视觉冲击 + 核心标题 |
|
||||
| **Setup (铺垫)** | Build context | 痛点共鸣 / 好奇心 |
|
||||
| **Core (核心)** | Deliver value | 干货内容,每页1-2个要点 |
|
||||
| **Payoff (收获)** | Practical takeaway | 可执行的行动建议 |
|
||||
| **Ending (结尾)** | Drive action | CTA + 互动引导 |
|
||||
|
||||
**Swipe Motivation Between Images**:
|
||||
- End each image with a hook for the next
|
||||
- Use "下一页更精彩" type transitions
|
||||
- Create information gaps that require swiping
|
||||
- Build anticipation through numbering ("第3个最重要")
|
||||
|
||||
## Output Format
|
||||
|
||||
Analysis results should be saved to `analysis.md` with:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "5个让你效率翻倍的AI工具"
|
||||
topic: 干货分享
|
||||
content_type: 工具推荐
|
||||
source_language: zh
|
||||
user_language: zh
|
||||
recommended_image_count: 6
|
||||
---
|
||||
|
||||
## Target Audience
|
||||
|
||||
- **Primary**: 打工人、自由职业者 - 追求效率提升
|
||||
- **Secondary**: 学生党 - 写论文、做作业需要
|
||||
- **Tertiary**: 内容创作者 - 需要AI辅助
|
||||
|
||||
## Hook Analysis
|
||||
|
||||
**标题钩子评分**: ⭐⭐⭐⭐ (4/5)
|
||||
- ✓ 数字钩子: "5个"
|
||||
- ✓ 利益钩子: "效率翻倍"
|
||||
- △ 可增强: 加入身份标签 "打工人必看"
|
||||
|
||||
**建议优化**:
|
||||
- 原标题: "5个让你效率翻倍的AI工具"
|
||||
- 优化: "打工人必看!5个让我效率翻倍的AI神器"
|
||||
|
||||
## Value Proposition
|
||||
|
||||
**为什么用户要看?**
|
||||
1. **实用价值**: 直接可用的工具推荐
|
||||
2. **省时省力**: 不用自己筛选,直接抄作业
|
||||
3. **FOMO**: 别人都在用,我不能落后
|
||||
|
||||
**收藏理由**: 工具清单,需要时可以回来查
|
||||
|
||||
## Engagement Design
|
||||
|
||||
- **互动点**: 结尾问"你最常用哪个?"
|
||||
- **评论诱导**: "还有什么好用的工具评论区分享"
|
||||
- **分享触发**: 打工人会转发给同事
|
||||
|
||||
## Content Signals
|
||||
|
||||
- "AI工具" → tech + dense
|
||||
- "效率" → notion + list
|
||||
- "干货" → minimal + dense
|
||||
|
||||
## Swipe Flow
|
||||
|
||||
| Image | Position | Purpose | Hook |
|
||||
|-------|----------|---------|------|
|
||||
| 1 | Cover | 吸引停留 | 标题+视觉冲击 |
|
||||
| 2 | Setup | 建立共鸣 | 为什么需要AI工具 |
|
||||
| 3-5 | Core | 核心价值 | 每页1-2个工具详解 |
|
||||
| 6 | Ending | 行动引导 | 总结+互动引导 |
|
||||
|
||||
## Recommended Approaches
|
||||
|
||||
1. **Tech + Dense** - 专业科技感,适合干货分享 (recommended)
|
||||
2. **Notion + List** - 清爽知识卡片风格
|
||||
3. **Minimal + Balanced** - 简约高端,适合职场人群
|
||||
```
|
||||
|
||||
## Analysis Checklist
|
||||
|
||||
Before proceeding to outline generation:
|
||||
|
||||
- [ ] Can I identify the content type?
|
||||
- [ ] Is the hook strong enough? (≥3 stars)
|
||||
- [ ] Do I know the primary audience?
|
||||
- [ ] Have I identified save/share triggers?
|
||||
- [ ] Are there clear visual opportunities?
|
||||
- [ ] Is the swipe flow planned?
|
||||
- [ ] Have I selected 3 style+layout combinations?
|
||||
@@ -0,0 +1,30 @@
|
||||
# balanced
|
||||
|
||||
Standard content layout
|
||||
|
||||
## Information Density
|
||||
|
||||
- 3-4 key points per image
|
||||
- Whitespace: 40-50% of canvas
|
||||
|
||||
## Structure
|
||||
|
||||
- Title at top
|
||||
- 3-4 bullet points or sections below
|
||||
|
||||
## Text Elements
|
||||
|
||||
- Title + 3-4 bullet points or key messages
|
||||
|
||||
## Visual Balance
|
||||
|
||||
- Top-weighted title
|
||||
- Evenly distributed content below
|
||||
|
||||
## Best For
|
||||
|
||||
Regular content pages, tutorials, explanations
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
All styles work well
|
||||
@@ -0,0 +1,31 @@
|
||||
# comparison
|
||||
|
||||
Side-by-side contrast layout
|
||||
|
||||
## Information Density
|
||||
|
||||
- 2 main sections with 2-4 points each
|
||||
- Whitespace: 30-40% of canvas
|
||||
|
||||
## Structure
|
||||
|
||||
- Left vs Right layout
|
||||
- Before/After, Pros/Cons format
|
||||
- Clear divider between sections
|
||||
|
||||
## Text Elements
|
||||
|
||||
- Title + left label + right label + mirrored points
|
||||
|
||||
## Visual Balance
|
||||
|
||||
- Symmetrical left/right
|
||||
- Clear visual contrast
|
||||
|
||||
## Best For
|
||||
|
||||
Comparisons, transformations, decision helpers, 对比图
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
bold (dramatic contrast), tech (data comparison), warm (before/after stories)
|
||||
@@ -0,0 +1,31 @@
|
||||
# dense
|
||||
|
||||
High information density, knowledge card style
|
||||
|
||||
## Information Density
|
||||
|
||||
- 5-8 key points per image
|
||||
- Whitespace: 20-30% of canvas
|
||||
|
||||
## Structure
|
||||
|
||||
- Multiple sections, structured grid
|
||||
- More text, compact but organized
|
||||
|
||||
## Text Elements
|
||||
|
||||
- Title + multiple sections with headers + numerous points
|
||||
|
||||
## Visual Balance
|
||||
|
||||
- Organized chaos
|
||||
- Clear section boundaries
|
||||
- Compact spacing
|
||||
|
||||
## Best For
|
||||
|
||||
Summary cards, cheat sheets, comprehensive guides, 干货总结
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
tech, notion, minimal (clean styles prevent visual overload)
|
||||
@@ -0,0 +1,30 @@
|
||||
# flow
|
||||
|
||||
Process and timeline layout
|
||||
|
||||
## Information Density
|
||||
|
||||
- 3-6 steps/stages
|
||||
- Whitespace: 30-40% of canvas
|
||||
|
||||
## Structure
|
||||
|
||||
- Connected nodes with arrows
|
||||
- Sequential flow, directional
|
||||
|
||||
## Text Elements
|
||||
|
||||
- Title + step labels + optional descriptions per step
|
||||
|
||||
## Visual Balance
|
||||
|
||||
- Directional flow (top→bottom or left→right)
|
||||
- Clear progression
|
||||
|
||||
## Best For
|
||||
|
||||
Processes, timelines, cause-effect chains, workflows
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
tech (process diagrams), notion (simple flows), fresh (organic flows)
|
||||
@@ -0,0 +1,31 @@
|
||||
# list
|
||||
|
||||
Enumeration and ranking format
|
||||
|
||||
## Information Density
|
||||
|
||||
- 4-7 items
|
||||
- Whitespace: 30-40% of canvas
|
||||
|
||||
## Structure
|
||||
|
||||
- Numbered or bulleted vertical list
|
||||
- Consistent item format
|
||||
|
||||
## Text Elements
|
||||
|
||||
- Title + numbered/bulleted items
|
||||
- Consistent format per item
|
||||
|
||||
## Visual Balance
|
||||
|
||||
- Left-aligned list
|
||||
- Clear number/bullet hierarchy
|
||||
|
||||
## Best For
|
||||
|
||||
Top N lists, checklists, step-by-step guides, rankings
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
All styles, especially cute (checklist), bold (rankings)
|
||||
@@ -0,0 +1,31 @@
|
||||
# sparse
|
||||
|
||||
Minimal information, maximum impact
|
||||
|
||||
## Information Density
|
||||
|
||||
- 1-2 key points per image
|
||||
- Whitespace: 60-70% of canvas
|
||||
|
||||
## Structure
|
||||
|
||||
- Single focal point centered
|
||||
- One core message
|
||||
- Single centered focal point
|
||||
|
||||
## Text Elements
|
||||
|
||||
- Title only, or title + one subtitle/tagline
|
||||
|
||||
## Visual Balance
|
||||
|
||||
- Centered, symmetrical
|
||||
- Breathing room on all sides
|
||||
|
||||
## Best For
|
||||
|
||||
Covers, quotes, impactful statements, emotional content
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
Any style, especially effective with bold, minimal, notion
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user