mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 06:19:46 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6194f71378 | |||
| 8e88cf4a8b | |||
| 259baff413 | |||
| a42137ff13 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "0.5.0"
|
"version": "0.5.2"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
+91
-335
@@ -5,384 +5,140 @@ description: Knowledge comic creator supporting multiple styles (Logicomix/Ligne
|
|||||||
|
|
||||||
# Knowledge Comic Creator
|
# Knowledge Comic Creator
|
||||||
|
|
||||||
Create original knowledge comics supporting multiple visual styles: Logicomix-style Ligne Claire, Ohmsha manga guide, and more.
|
Create original knowledge comics with multiple visual styles.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From source material
|
|
||||||
/baoyu-comic posts/turing-story/source.md
|
/baoyu-comic posts/turing-story/source.md
|
||||||
|
/baoyu-comic posts/turing-story/source.md --style dramatic --layout cinematic
|
||||||
# Specify style
|
/baoyu-comic # then paste content
|
||||||
/baoyu-comic posts/turing-story/source.md --style dramatic
|
|
||||||
|
|
||||||
# Specify layout preference
|
|
||||||
/baoyu-comic posts/turing-story/source.md --layout cinematic
|
|
||||||
|
|
||||||
# Direct content input
|
|
||||||
/baoyu-comic
|
|
||||||
[paste story content or topic description]
|
|
||||||
|
|
||||||
# Direct input with options
|
|
||||||
/baoyu-comic --style warm --layout dense
|
|
||||||
[paste content]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Values |
|
||||||
|--------|-------------|
|
|--------|--------|
|
||||||
| `--style <name>` | Visual style (see Style Gallery) |
|
| `--style` | classic (default), dramatic, warm, tech, sepia, vibrant, ohmsha |
|
||||||
| `--layout <name>` | Panel layout preference (see Layout Gallery) |
|
| `--layout` | standard (default), cinematic, dense, splash, mixed, webtoon |
|
||||||
|
|
||||||
## Two Dimensions
|
Style × Layout can be freely combined.
|
||||||
|
|
||||||
| Dimension | Controls | Options |
|
## Auto Selection
|
||||||
|-----------|----------|---------|
|
|
||||||
| **Style** | Color palette, mood, era treatment | classic, dramatic, warm, tech, sepia, vibrant, ohmsha |
|
|
||||||
| **Layout** | Panel arrangement, density | standard, cinematic, dense, splash, mixed, webtoon |
|
|
||||||
|
|
||||||
Style × Layout can be freely combined. Example: `--style dramatic --layout cinematic` creates high-contrast panels with wide aspect ratios.
|
| 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 |
|
||||||
|
| Biography, balanced | classic | mixed |
|
||||||
|
|
||||||
## Style Gallery
|
## File Structure
|
||||||
|
|
||||||
| Style | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `classic` (Default) | Traditional Ligne Claire, balanced and timeless |
|
|
||||||
| `dramatic` | High contrast, intense moments |
|
|
||||||
| `warm` | Nostalgic, personal storytelling |
|
|
||||||
| `tech` | Modern, digital-age narratives |
|
|
||||||
| `sepia` | Historical, archival feel |
|
|
||||||
| `vibrant` | Energetic, engaging, educational |
|
|
||||||
| `ohmsha` | Ohmsha Manga Guide style - educational manga with visual metaphors |
|
|
||||||
|
|
||||||
Detailed style definitions: `references/styles/<style>.md`
|
|
||||||
|
|
||||||
## Layout Gallery
|
|
||||||
|
|
||||||
| Layout | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `standard` (Default) | Classic comic grid, versatile (4-6 panels) |
|
|
||||||
| `cinematic` | Wide panels, filmic feel (2-4 panels) |
|
|
||||||
| `dense` | Information-rich, educational focus (6-9 panels) |
|
|
||||||
| `splash` | Impact-focused, key moments (1-2 large + 2-3 small) |
|
|
||||||
| `mixed` | Dynamic, varied rhythm (3-7 panels) |
|
|
||||||
| `webtoon` | Vertical scrolling comic (竖版条漫) |
|
|
||||||
|
|
||||||
Detailed layout definitions: `references/layouts/<layout>.md`
|
|
||||||
|
|
||||||
## Auto Style Selection
|
|
||||||
|
|
||||||
When no `--style` is specified, analyze content to select:
|
|
||||||
|
|
||||||
| Content Signals | Selected Style |
|
|
||||||
|----------------|----------------|
|
|
||||||
| Tutorial, how-to, learning guide, beginner | `ohmsha` |
|
|
||||||
| Computing, AI, digital, programming | `tech` |
|
|
||||||
| Pre-1950, classical, ancient | `sepia` |
|
|
||||||
| Personal story, childhood, mentor | `warm` |
|
|
||||||
| Conflict, struggle, breakthrough | `dramatic` |
|
|
||||||
| Young audience, science basics, wonder | `vibrant` |
|
|
||||||
| Balanced narrative, biography | `classic` |
|
|
||||||
|
|
||||||
## Auto Layout Selection
|
|
||||||
|
|
||||||
When no `--layout` is specified, analyze content to select:
|
|
||||||
|
|
||||||
| Content Signals | Selected Layout |
|
|
||||||
|----------------|-----------------|
|
|
||||||
| Technical explanation, timeline | `dense` |
|
|
||||||
| Key discovery, major event | `splash` |
|
|
||||||
| Establishing scene, location | `cinematic` |
|
|
||||||
| Dialogue, conversation, debate | `standard` |
|
|
||||||
| Mixed content, varied pacing | `mixed` |
|
|
||||||
| Ohmsha style, tutorial, vertical scroll | `webtoon` |
|
|
||||||
|
|
||||||
## Style × Layout Matrix
|
|
||||||
|
|
||||||
| | standard | cinematic | dense | splash | mixed | webtoon |
|
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| classic | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
|
||||||
| dramatic | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
|
||||||
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
| tech | ✓✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ |
|
|
||||||
| sepia | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
| vibrant | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ |
|
|
||||||
| ohmsha | ✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ |
|
|
||||||
|
|
||||||
✓✓ = highly recommended, ✓ = works well
|
|
||||||
|
|
||||||
## File Management
|
|
||||||
|
|
||||||
### With Source Path
|
|
||||||
|
|
||||||
Save to `comic/` subdirectory in the same folder as the source:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
posts/turing-story/
|
[target]/
|
||||||
├── source.md
|
├── outline.md
|
||||||
└── comic/
|
├── characters/
|
||||||
├── outline.md
|
│ ├── characters.md # Character definitions
|
||||||
├── prompts/
|
│ └── characters.png # Character reference sheet
|
||||||
│ ├── 00-cover.md
|
├── prompts/
|
||||||
│ ├── 01-page.md
|
│ ├── 00-cover.md
|
||||||
│ └── ...
|
│ └── XX-page.md
|
||||||
├── 00-cover.png
|
├── 00-cover.png
|
||||||
├── 01-page.png
|
└── XX-page.png
|
||||||
└── ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Without Source Path
|
**Target directory**:
|
||||||
|
- With source path: `[source-dir]/comic/`
|
||||||
Save to `comic-outputs/YYYY-MM-DD/[topic-slug]/`:
|
- Without source: `comic-outputs/YYYY-MM-DD/[topic-slug]/`
|
||||||
|
|
||||||
```
|
|
||||||
comic-outputs/
|
|
||||||
└── 2026-01-16/
|
|
||||||
└── turing-biography/
|
|
||||||
├── outline.md
|
|
||||||
├── prompts/
|
|
||||||
│ ├── 00-cover.md
|
|
||||||
│ └── ...
|
|
||||||
├── 00-cover.png
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Directory collision**: If slug exists, generate a different slug (do not overwrite).
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### Step 1: Analyze Content & Determine Scope
|
### Step 1: Analyze Content
|
||||||
|
|
||||||
1. Read source content
|
1. Read source content
|
||||||
2. If `--style` specified, use it; otherwise auto-select
|
2. Select style (from `--style` or auto-detect)
|
||||||
3. If `--layout` specified, use it; otherwise auto-select per page
|
3. Select layout (from `--layout` or auto-detect per page)
|
||||||
4. Determine page count based on content complexity:
|
4. Determine page count:
|
||||||
|
- Short story: 5-8 pages
|
||||||
|
- Medium complexity: 9-15 pages
|
||||||
|
- Full biography: 16-25 pages
|
||||||
|
|
||||||
| Content Type | Page Count (incl. cover) |
|
### Step 2: Define Characters
|
||||||
|-------------|--------------------------|
|
|
||||||
| Single event / short story | 5-8 |
|
|
||||||
| Medium complexity / mini biography | 9-15 |
|
|
||||||
| Full biography / multi-thread | 16-25 |
|
|
||||||
|
|
||||||
### Step 2: Generate Outline
|
**Purpose**: Establish visual consistency across all pages.
|
||||||
|
|
||||||
Plan cover + each page with detailed panel breakdowns:
|
1. Extract all characters from content (protagonist, supporting, antagonist, narrator)
|
||||||
|
2. Create `characters/characters.md` with visual specs for each character
|
||||||
|
3. Generate `characters/characters.png` (character reference sheet)
|
||||||
|
|
||||||
```markdown
|
**Reference**: `references/character-template.md` for detailed format and examples.
|
||||||
# [Comic Title] - Knowledge Biography Comic Outline
|
|
||||||
|
|
||||||
**Topic**: [topic description]
|
### Step 3: Generate Outline
|
||||||
**Time Span**: [e.g., 1912-1954]
|
|
||||||
**Style**: [selected style]
|
|
||||||
**Default Layout**: [selected layout or "varies"]
|
|
||||||
**Page Count**: Cover + N pages
|
|
||||||
**Generated**: YYYY-MM-DD HH:mm
|
|
||||||
|
|
||||||
---
|
Create `outline.md` with:
|
||||||
|
- Metadata (title, style, layout, page count, character reference path)
|
||||||
|
- Cover design
|
||||||
|
- Each page: layout, panel breakdown, visual prompts
|
||||||
|
|
||||||
## Cover
|
**Reference**: `references/outline-template.md` for detailed format.
|
||||||
|
|
||||||
**Filename**: 00-cover.png
|
### Step 4: Generate Images
|
||||||
**Core Message**: [one-liner]
|
|
||||||
|
|
||||||
**Visual Design**:
|
For each page (cover + pages):
|
||||||
- Title typography style
|
|
||||||
- Main visual composition
|
|
||||||
- Color scheme
|
|
||||||
- Subtitle / time span notation
|
|
||||||
|
|
||||||
**Visual Prompt**:
|
1. Save prompt to `prompts/XX-page.md`
|
||||||
[Detailed image generation prompt]
|
2. Call image generation skill with:
|
||||||
|
- Base prompt: `references/base-prompt.md`
|
||||||
|
- Character reference (text or image, depending on skill capability)
|
||||||
|
- Page prompt
|
||||||
|
- Output path
|
||||||
|
|
||||||
---
|
**Image Generation Skill Selection**:
|
||||||
|
- Check available image generation skills in the environment
|
||||||
|
- Adapt parameters based on skill capabilities:
|
||||||
|
- If supports `--promptfiles`: pass prompt files
|
||||||
|
- If supports reference image: pass `characters/characters.png`
|
||||||
|
- If text-only: concatenate prompts into single text
|
||||||
|
- If multiple skills available, ask user preference
|
||||||
|
|
||||||
## Page 1 / N
|
**Session Management**:
|
||||||
|
If the image generation skill supports `--sessionId`:
|
||||||
|
1. Generate a unique session ID at the start (e.g., `comic-{topic-slug}-{timestamp}`)
|
||||||
|
2. Use the same session ID for character sheet and all pages
|
||||||
|
3. This ensures visual consistency (character appearance, style) across all generated images
|
||||||
|
|
||||||
**Filename**: 01-page.png
|
3. Report progress after each generation
|
||||||
**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
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Save Outline
|
|
||||||
|
|
||||||
Save as `outline.md` in target directory.
|
|
||||||
|
|
||||||
### Step 4: Generate Images Page by Page
|
|
||||||
|
|
||||||
For each page:
|
|
||||||
|
|
||||||
1. **Save prompt file** to `prompts/` subdirectory
|
|
||||||
2. **Call image generation skill**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts \
|
|
||||||
--promptfiles skills/baoyu-comic/prompts/system.md [target]/prompts/00-cover.md \
|
|
||||||
--image [target]/00-cover.png
|
|
||||||
```
|
|
||||||
|
|
||||||
After each generation:
|
|
||||||
1. Confirm success
|
|
||||||
2. Report progress: "Generated X/N pages"
|
|
||||||
3. Continue to next
|
|
||||||
|
|
||||||
### Step 5: Completion Report
|
### Step 5: Completion Report
|
||||||
|
|
||||||
```
|
```
|
||||||
Knowledge Biography Comic Complete!
|
Comic Complete!
|
||||||
|
Title: [title] | Style: [style] | Pages: [count]
|
||||||
Title: [Comic Title]
|
Location: [path]
|
||||||
Time Span: [e.g., 1912-1954]
|
✓ characters.png
|
||||||
Style: [style name]
|
✓ 00-cover.png ... XX-page.png
|
||||||
Layout: [layout name or "varies"]
|
|
||||||
Location: [directory path]
|
|
||||||
Pages: Cover + N pages
|
|
||||||
|
|
||||||
- 00-cover.png ✓ Cover
|
|
||||||
- 01-page.png ✓ Opening: [brief]
|
|
||||||
- 02-page.png ✓ [brief]
|
|
||||||
- ...
|
|
||||||
- XX-page.png ✓ Ending: [brief]
|
|
||||||
|
|
||||||
Outline: outline.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Narrative Structure Principles
|
## Style-Specific Guidelines
|
||||||
|
|
||||||
### Cover Design
|
### Ohmsha Style (`--style ohmsha`)
|
||||||
|
|
||||||
- Academic gravitas with visual appeal
|
Additional requirements for educational manga:
|
||||||
- Title typography reflecting knowledge/science theme
|
- Default characters: Student (大雄), Mentor (哆啦A梦), Antagonist (胖虎)
|
||||||
- Composition hinting at core theme (character silhouette, iconic symbol, concept diagram)
|
- Custom: `--characters "Student:小明,Mentor:教授"`
|
||||||
- Subtitle or time span for epic scope
|
- Must use visual metaphors (gadgets, action scenes) - NO talking heads
|
||||||
|
- Page titles: narrative style, not "Page X: Topic"
|
||||||
|
|
||||||
### Panel Composition Principles
|
**Reference**: `references/ohmsha-guide.md` for detailed guidelines.
|
||||||
|
|
||||||
| Panel Type | Recommended Count | Usage |
|
## References
|
||||||
|-----------|-------------------|-------|
|
|
||||||
| Main narrative | 3-5 per page | Story progression |
|
|
||||||
| Concept diagram | 1-2 per page | Visualize abstractions |
|
|
||||||
| Narrator panel | 0-1 per page | Commentary, transition, questions |
|
|
||||||
| Splash (full/half) | Occasional | Major moments |
|
|
||||||
|
|
||||||
### Panel Size Reference
|
Detailed templates and guidelines in `references/` directory:
|
||||||
|
- `character-template.md` - Character definition format and examples
|
||||||
- **Full page (Splash)**: Major moments, key breakthroughs
|
- `outline-template.md` - Outline structure and panel breakdown
|
||||||
- **Half page**: Important scenes, turning points
|
- `ohmsha-guide.md` - Ohmsha manga style specifics
|
||||||
- **1/3 page**: Standard narrative panels
|
- `styles/` - Detailed style definitions
|
||||||
- **1/4 or smaller**: Quick progression, sequential action
|
- `layouts/` - Detailed layout definitions
|
||||||
|
|
||||||
### 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 / hand-drawn 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 |
|
|
||||||
|
|
||||||
## Ohmsha Style Special Requirements
|
|
||||||
|
|
||||||
When using `--style ohmsha`, follow these additional guidelines:
|
|
||||||
|
|
||||||
### Character Setup (Required)
|
|
||||||
|
|
||||||
Define characters before generating outline:
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
User can specify custom characters via `--characters "Student:小明,Mentor:教授,Antagonist:Bug怪"`
|
|
||||||
|
|
||||||
### Outline Spec Block (Required)
|
|
||||||
|
|
||||||
Every ohmsha outline must start with a spec block:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
【漫画规格单】
|
|
||||||
- Language: [Same as input content]
|
|
||||||
- Style: Ohmsha (Manga Guide), Full Color
|
|
||||||
- Layout: Vertical Scrolling Comic (竖版条漫)
|
|
||||||
- Characters: [List character names and roles]
|
|
||||||
- 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
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Image generation typically takes 30-60 seconds per page
|
|
||||||
- Auto-retry once on generation failure
|
|
||||||
- Historical figures rendered in portrait/illustration style for recognizability
|
|
||||||
- All text in Chinese unless source is in another language
|
|
||||||
- Each panel description should be detailed enough to serve as standalone AI art prompt
|
|
||||||
- Maintain style consistency across all pages in series
|
|
||||||
|
|||||||
@@ -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,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,136 @@
|
|||||||
|
# Outline Template
|
||||||
|
|
||||||
|
## Outline Document Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Comic Title] - Knowledge Comic Outline
|
||||||
|
|
||||||
|
**Topic**: [topic description]
|
||||||
|
**Time Span**: [e.g., 1912-1954]
|
||||||
|
**Style**: [selected style]
|
||||||
|
**Default Layout**: [selected layout or "varies"]
|
||||||
|
**Page Count**: Cover + N pages
|
||||||
|
**Character Reference**: characters/characters.png
|
||||||
|
**Generated**: YYYY-MM-DD HH:mm
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cover
|
||||||
|
|
||||||
|
**Filename**: 00-cover.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.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]
|
||||||
|
```
|
||||||
@@ -5,6 +5,13 @@ description: Image generation skill using Gemini Web. Generates images from text
|
|||||||
|
|
||||||
# Gemini Web Client
|
# Gemini Web Client
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Text generation
|
||||||
|
- Image generation (download + save)
|
||||||
|
- Reference image upload (attach images for vision tasks)
|
||||||
|
- Multi-turn conversations within the same executor instance (`keepSession`)
|
||||||
|
- Experimental video generation (`generateVideo`) — Gemini may return an async placeholder; download might require Gemini web UI
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -12,8 +19,25 @@ 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 "Explain quantum computing"
|
||||||
npx -y bun scripts/main.ts --prompt "A cute cat" --image cat.png
|
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 scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||||
|
|
||||||
|
# Multi-turn conversation (agent generates unique sessionId)
|
||||||
|
npx -y bun scripts/main.ts "Remember this: 42" --sessionId my-unique-id-123
|
||||||
|
npx -y bun scripts/main.ts "What number?" --sessionId my-unique-id-123
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Executor options (programmatic)
|
||||||
|
|
||||||
|
This skill is typically consumed via `createGeminiWebExecutor(geminiOptions)` (see `scripts/executor.ts`).
|
||||||
|
|
||||||
|
Key options in `GeminiWebOptions`:
|
||||||
|
- `referenceImages?: string | string[]` Upload local images as references (vision input).
|
||||||
|
- `keepSession?: boolean` Reuse Gemini `chatMetadata` to continue the same conversation across calls (required if you want reference images to persist across multiple messages).
|
||||||
|
- `generateVideo?: string` Generate a video and (best-effort) download to the given path. Gemini may return `video_gen_chip` (async); in that case you must open Gemini web UI to download the result.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `generateVideo` cannot be combined with `generateImage` / `editImage`.
|
||||||
|
- When `keepSession=true` and `referenceImages` is set, reference images are uploaded once per executor instance.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Text generation
|
### Text generation
|
||||||
@@ -58,17 +82,21 @@ npx -y bun scripts/main.ts "Hello" --json
|
|||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Option | Short | Description |
|
| Option | Description |
|
||||||
|--------|-------|-------------|
|
|--------|-------------|
|
||||||
| `--prompt <text>` | `-p` | Prompt text |
|
| `--prompt <text>`, `-p` | Prompt text |
|
||||||
| `--promptfiles <files...>` | | Read prompt from files (concatenated in order) |
|
| `--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 |
|
| `--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) |
|
| `--image [path]` | Generate image, save to path (default: generated.png) |
|
||||||
| `--json` | | Output as JSON |
|
| `--sessionId <id>` | Session ID for multi-turn conversation (agent generates unique ID) |
|
||||||
| `--login` | | Refresh cookies only, then exit |
|
| `--list-sessions` | List saved sessions (max 100, sorted by update time) |
|
||||||
| `--cookie-path <path>` | | Custom cookie file path |
|
| `--json` | Output as JSON |
|
||||||
| `--profile-dir <path>` | | Chrome profile directory |
|
| `--login` | Refresh cookies only, then exit |
|
||||||
| `--help` | `-h` | Show help |
|
| `--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, and multi-turn conversations via `--sessionId`. Reference images and video generation are exposed via the executor API.
|
||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
@@ -116,3 +144,22 @@ npx -y bun scripts/main.ts "Hello" --json | jq '.text'
|
|||||||
# Concatenate system.md + content.md as prompt
|
# Concatenate system.md + content.md as prompt
|
||||||
npx -y bun scripts/main.ts --promptfiles system.md content.md --image output.png
|
npx -y bun 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 scripts/main.ts "You are a helpful math tutor." --sessionId task-abc123
|
||||||
|
|
||||||
|
# Continue the conversation (remembers context)
|
||||||
|
npx -y bun scripts/main.ts "What is 2+2?" --sessionId task-abc123
|
||||||
|
npx -y bun scripts/main.ts "Now multiply that by 10" --sessionId task-abc123
|
||||||
|
|
||||||
|
# List recent sessions (max 100, sorted by update time)
|
||||||
|
npx -y bun 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
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ class CdpConnection {
|
|||||||
export async function getGeminiCookieMapViaChrome(options?: {
|
export async function getGeminiCookieMapViaChrome(options?: {
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
debugConnectTimeoutMs?: number;
|
debugConnectTimeoutMs?: number;
|
||||||
|
tokenCheckTimeoutMs?: number;
|
||||||
pollIntervalMs?: number;
|
pollIntervalMs?: number;
|
||||||
log?: GeminiWebLog;
|
log?: GeminiWebLog;
|
||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
@@ -224,6 +225,7 @@ export async function getGeminiCookieMapViaChrome(options?: {
|
|||||||
const log = options?.log;
|
const log = options?.log;
|
||||||
const timeoutMs = options?.timeoutMs ?? 5 * 60_000;
|
const timeoutMs = options?.timeoutMs ?? 5 * 60_000;
|
||||||
const debugConnectTimeoutMs = options?.debugConnectTimeoutMs ?? 30_000;
|
const debugConnectTimeoutMs = options?.debugConnectTimeoutMs ?? 30_000;
|
||||||
|
const tokenCheckTimeoutMs = options?.tokenCheckTimeoutMs ?? 30_000;
|
||||||
const pollIntervalMs = options?.pollIntervalMs ?? 2_000;
|
const pollIntervalMs = options?.pollIntervalMs ?? 2_000;
|
||||||
const userDataDir = options?.userDataDir ?? resolveGeminiWebChromeProfileDir();
|
const userDataDir = options?.userDataDir ?? resolveGeminiWebChromeProfileDir();
|
||||||
|
|
||||||
@@ -290,7 +292,7 @@ export async function getGeminiCookieMapViaChrome(options?: {
|
|||||||
if (hasRequiredGeminiCookies(cookieMap)) {
|
if (hasRequiredGeminiCookies(cookieMap)) {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
const timer = setTimeout(() => controller.abort(), tokenCheckTimeoutMs);
|
||||||
try {
|
try {
|
||||||
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -67,19 +67,69 @@ function buildCookieHeader(cookieMap: Record<string, string>): string {
|
|||||||
.join('; ');
|
.join('; ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSetCookieHeaders(res: Response): string[] {
|
||||||
|
const headers = res.headers as unknown as { getSetCookie?: () => string[] };
|
||||||
|
if (typeof headers.getSetCookie === 'function') {
|
||||||
|
try {
|
||||||
|
return headers.getSetCookie();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const raw = res.headers.get('set-cookie');
|
||||||
|
return raw ? [raw] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySetCookiesToMap(setCookies: string[], cookieMap: Record<string, string>): void {
|
||||||
|
for (const raw of setCookies) {
|
||||||
|
const first = raw.split(';')[0]?.trim();
|
||||||
|
if (!first) continue;
|
||||||
|
const idx = first.indexOf('=');
|
||||||
|
if (idx <= 0) continue;
|
||||||
|
const name = first.slice(0, idx).trim();
|
||||||
|
const value = first.slice(idx + 1).trim();
|
||||||
|
if (!name) continue;
|
||||||
|
cookieMap[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithCookieJar(
|
||||||
|
url: string,
|
||||||
|
init: Omit<RequestInit, 'redirect' | 'headers'> & { headers?: Record<string, string> },
|
||||||
|
cookieMap: Record<string, string>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
maxRedirects = 20,
|
||||||
|
): Promise<Response> {
|
||||||
|
let current = url;
|
||||||
|
for (let i = 0; i <= maxRedirects; i += 1) {
|
||||||
|
const cookieHeader = buildCookieHeader(cookieMap);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
||||||
|
'user-agent': USER_AGENT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(current, { ...init, redirect: 'manual', signal, headers });
|
||||||
|
applySetCookiesToMap(getSetCookieHeaders(res), cookieMap);
|
||||||
|
|
||||||
|
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 fetching ${url} (>${maxRedirects}).`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchGeminiAccessToken(
|
export async function fetchGeminiAccessToken(
|
||||||
cookieMap: Record<string, string>,
|
cookieMap: Record<string, string>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const cookieHeader = buildCookieHeader(cookieMap);
|
const res = await fetchWithCookieJar(GEMINI_APP_URL, { method: 'GET' }, cookieMap, signal);
|
||||||
const res = await fetch(GEMINI_APP_URL, {
|
|
||||||
redirect: 'follow',
|
|
||||||
signal,
|
|
||||||
headers: {
|
|
||||||
cookie: cookieHeader,
|
|
||||||
'user-agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
|
|
||||||
const tokens = ['SNlM0e', 'thykhd'] as const;
|
const tokens = ['SNlM0e', 'thykhd'] as const;
|
||||||
@@ -107,7 +157,21 @@ function extractErrorCode(responseJson: unknown): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractGgdlUrls(rawText: string): string[] {
|
function extractGgdlUrls(rawText: string): string[] {
|
||||||
const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
|
const matches =
|
||||||
|
rawText.match(/https?:\/\/[^/\s"']*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 extractImageGenerationContentUrls(rawText: string): string[] {
|
||||||
|
const matches =
|
||||||
|
rawText.match(/https?:\/\/googleusercontent\.com\/image_generation_content\/\d+/g) ?? [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
@@ -119,9 +183,17 @@ function extractGgdlUrls(rawText: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureFullSizeImageUrl(url: string): string {
|
function ensureFullSizeImageUrl(url: string): string {
|
||||||
if (url.includes('=s2048')) return url;
|
const trimmed = url.trim();
|
||||||
if (url.includes('=s')) return url;
|
let normalized = trimmed;
|
||||||
return `${url}=s2048`;
|
const backslashIndex = normalized.indexOf('\\');
|
||||||
|
if (backslashIndex >= 0) normalized = normalized.slice(0, backslashIndex);
|
||||||
|
// Some Gemini responses embed a size suffix as "/=s2048" which breaks downloads.
|
||||||
|
normalized = normalized.replace(/\/=s(?=\d+(?:$|[?#]))/, '=s');
|
||||||
|
normalized = normalized.replace(/\/=s(?=$|[?#])/, '=s');
|
||||||
|
if (normalized.endsWith('/')) normalized = normalized.slice(0, -1);
|
||||||
|
if (normalized.includes('=s2048')) return normalized;
|
||||||
|
if (normalized.includes('=s')) return normalized;
|
||||||
|
return `${normalized}=s2048`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithCookiePreservingRedirects(
|
async function fetchWithCookiePreservingRedirects(
|
||||||
@@ -190,6 +262,29 @@ async function uploadGeminiFile(filePath: string, signal?: AbortSignal): Promise
|
|||||||
return { id: text, name: fileName };
|
return { id: text, name: fileName };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function guessMimeType(fileName: string): string {
|
||||||
|
const ext = path.extname(fileName).toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case '.png':
|
||||||
|
return 'image/png';
|
||||||
|
case '.jpg':
|
||||||
|
case '.jpeg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case '.webp':
|
||||||
|
return 'image/webp';
|
||||||
|
case '.gif':
|
||||||
|
return 'image/gif';
|
||||||
|
case '.mp4':
|
||||||
|
return 'video/mp4';
|
||||||
|
case '.mov':
|
||||||
|
return 'video/quicktime';
|
||||||
|
case '.webm':
|
||||||
|
return 'video/webm';
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildGeminiFReqPayload(
|
function buildGeminiFReqPayload(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
uploaded: Array<{ id: string; name: string }>,
|
uploaded: Array<{ id: string; name: string }>,
|
||||||
@@ -201,9 +296,8 @@ function buildGeminiFReqPayload(
|
|||||||
prompt,
|
prompt,
|
||||||
0,
|
0,
|
||||||
null,
|
null,
|
||||||
// Matches gemini-webapi payload format: [[[fileId, 1]]] for a single attachment.
|
// Matches gemini-web payload format: [[[fileId, 1, null, mimeType], fileName]] for an attachment.
|
||||||
// Keep it extensible for multiple uploads by emitting one [[id, 1]] entry per file.
|
uploaded.map((file) => [[file.id, 1, null, guessMimeType(file.name)], file.name]),
|
||||||
uploaded.map((file) => [[file.id, 1]]),
|
|
||||||
]
|
]
|
||||||
: [prompt];
|
: [prompt];
|
||||||
|
|
||||||
@@ -248,7 +342,19 @@ export function parseGeminiStreamGenerateResponse(rawText: string): {
|
|||||||
? (getNestedValue<string | null>(firstCandidate, [22, 0], null) ?? textRaw)
|
? (getNestedValue<string | null>(firstCandidate, [22, 0], null) ?? textRaw)
|
||||||
: textRaw;
|
: textRaw;
|
||||||
const thoughts = getNestedValue<string | null>(firstCandidate, [37, 0, 0], null);
|
const thoughts = getNestedValue<string | null>(firstCandidate, [37, 0, 0], null);
|
||||||
const metadata = getNestedValue<unknown>(body, [1], []);
|
const conversationMeta = getNestedValue<unknown[]>(body, [1], []);
|
||||||
|
const conversationId =
|
||||||
|
typeof conversationMeta[0] === 'string' && conversationMeta[0].length > 0
|
||||||
|
? conversationMeta[0]
|
||||||
|
: null;
|
||||||
|
const responseId =
|
||||||
|
typeof conversationMeta[1] === 'string' && conversationMeta[1].length > 0
|
||||||
|
? conversationMeta[1]
|
||||||
|
: null;
|
||||||
|
const choiceIdRaw = getNestedValue<string | null>(firstCandidate, [0], null);
|
||||||
|
const choiceId = typeof choiceIdRaw === 'string' && choiceIdRaw.length > 0 ? choiceIdRaw : null;
|
||||||
|
const metadata =
|
||||||
|
conversationId && responseId && choiceId ? [conversationId, responseId, choiceId] : conversationMeta;
|
||||||
|
|
||||||
const images: GeminiWebCandidateImage[] = [];
|
const images: GeminiWebCandidateImage[] = [];
|
||||||
|
|
||||||
@@ -305,8 +411,8 @@ export function isGeminiModelUnavailable(errorCode: number | undefined): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runGeminiWebOnce(input: GeminiWebRunInput): Promise<GeminiWebRunOutput> {
|
export async function runGeminiWebOnce(input: GeminiWebRunInput): Promise<GeminiWebRunOutput> {
|
||||||
const cookieHeader = buildCookieHeader(input.cookieMap);
|
|
||||||
const at = await fetchGeminiAccessToken(input.cookieMap, input.signal);
|
const at = await fetchGeminiAccessToken(input.cookieMap, input.signal);
|
||||||
|
const cookieHeader = buildCookieHeader(input.cookieMap);
|
||||||
|
|
||||||
const uploaded: Array<{ id: string; name: string }> = [];
|
const uploaded: Array<{ id: string; name: string }> = [];
|
||||||
for (const file of input.files ?? []) {
|
for (const file of input.files ?? []) {
|
||||||
@@ -403,11 +509,19 @@ export async function saveFirstGeminiImageFromOutput(
|
|||||||
return { saved: true, imageCount: output.images.length };
|
return { saved: true, imageCount: output.images.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ggdl = extractGgdlUrls(output.rawResponseText);
|
const ggdl = extractGgdlUrls(`${output.text}\n${output.rawResponseText}`);
|
||||||
if (ggdl[0]) {
|
const preferred = ggdl.length > 0 ? ggdl[ggdl.length - 1] : null;
|
||||||
await downloadGeminiImage(ggdl[0], cookieMap, outputPath, signal);
|
if (preferred) {
|
||||||
|
await downloadGeminiImage(preferred, cookieMap, outputPath, signal);
|
||||||
return { saved: true, imageCount: ggdl.length };
|
return { saved: true, imageCount: ggdl.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageGen = extractImageGenerationContentUrls(`${output.text}\n${output.rawResponseText}`);
|
||||||
|
const imageGenPreferred = imageGen.length > 0 ? imageGen[imageGen.length - 1] : null;
|
||||||
|
if (imageGenPreferred) {
|
||||||
|
await downloadGeminiImage(imageGenPreferred, cookieMap, outputPath, signal);
|
||||||
|
return { saved: true, imageCount: imageGen.length };
|
||||||
|
}
|
||||||
|
|
||||||
return { saved: false, imageCount: 0 };
|
return { saved: false, imageCount: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { BrowserRunOptions, BrowserRunResult, BrowserLogger, CookieParam } from '../browser/types.js';
|
import type { BrowserRunOptions, BrowserRunResult, BrowserLogger, CookieParam } from '../browser/types.js';
|
||||||
import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
|
import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
|
||||||
import type { GeminiWebModelId } from './client.js';
|
import type { GeminiWebModelId, GeminiWebRunOutput } from './client.js';
|
||||||
import {
|
import {
|
||||||
buildGeminiCookieMap,
|
buildGeminiCookieMap,
|
||||||
hasRequiredGeminiCookies,
|
hasRequiredGeminiCookies,
|
||||||
@@ -11,6 +12,9 @@ import type { GeminiWebOptions, GeminiWebResponse } from './types.js';
|
|||||||
|
|
||||||
export { hasRequiredGeminiCookies } from './cookie-store.js';
|
export { hasRequiredGeminiCookies } from './cookie-store.js';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
function estimateTokenCount(text: string): number {
|
function estimateTokenCount(text: string): number {
|
||||||
return Math.ceil(text.length / 4);
|
return Math.ceil(text.length / 4);
|
||||||
}
|
}
|
||||||
@@ -22,6 +26,115 @@ function resolveInvocationPath(value: string | undefined): string | undefined {
|
|||||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
|
return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePathList(value: string | string[] | undefined): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
const raw = Array.isArray(value) ? value : [value];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (typeof entry !== 'string') continue;
|
||||||
|
const resolved = resolveInvocationPath(entry);
|
||||||
|
if (!resolved) continue;
|
||||||
|
out.push(resolved);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupePaths(paths: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const item of paths) {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (!trimmed || seen.has(trimmed)) continue;
|
||||||
|
seen.add(trimmed);
|
||||||
|
out.push(trimmed);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 media (>${maxRedirects}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadGeminiMedia(
|
||||||
|
url: string,
|
||||||
|
cookieMap: Record<string, string>,
|
||||||
|
outputPath: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const cookieHeader = buildCookieHeader(cookieMap);
|
||||||
|
const res = await fetchWithCookiePreservingRedirects(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
cookie: cookieHeader,
|
||||||
|
'user-agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to download media: ${res.status} ${res.statusText} (${res.url})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(await res.arrayBuffer());
|
||||||
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGgdlUrls(rawText: string): string[] {
|
||||||
|
const matches =
|
||||||
|
rawText.match(/https?:\/\/[^/\s"']*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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFirstGeminiVideoFromOutput(
|
||||||
|
output: GeminiWebRunOutput,
|
||||||
|
cookieMap: Record<string, string>,
|
||||||
|
outputPath: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ saved: boolean; videoCount: number }> {
|
||||||
|
const ggdl = extractGgdlUrls(output.rawResponseText);
|
||||||
|
if (!ggdl[0]) return { saved: false, videoCount: 0 };
|
||||||
|
|
||||||
|
const videoCandidates = ggdl.filter((url) => /\.(mp4|webm|mov)(?:$|[?#])/i.test(url));
|
||||||
|
const preferred =
|
||||||
|
(videoCandidates.length > 0 ? videoCandidates[videoCandidates.length - 1] : null) ??
|
||||||
|
ggdl.find((url) => /video/i.test(url)) ??
|
||||||
|
ggdl[ggdl.length - 1];
|
||||||
|
await downloadGeminiMedia(preferred, cookieMap, outputPath, signal);
|
||||||
|
return { saved: true, videoCount: ggdl.length };
|
||||||
|
}
|
||||||
|
|
||||||
function resolveGeminiWebModel(
|
function resolveGeminiWebModel(
|
||||||
desiredModel: string | null | undefined,
|
desiredModel: string | null | undefined,
|
||||||
log?: BrowserLogger,
|
log?: BrowserLogger,
|
||||||
@@ -115,6 +228,9 @@ export async function loadGeminiCookieMap(log?: BrowserLogger): Promise<Record<s
|
|||||||
export function createGeminiWebExecutor(
|
export function createGeminiWebExecutor(
|
||||||
geminiOptions: GeminiWebOptions,
|
geminiOptions: GeminiWebOptions,
|
||||||
): (runOptions: BrowserRunOptions) => Promise<BrowserRunResult> {
|
): (runOptions: BrowserRunOptions) => Promise<BrowserRunResult> {
|
||||||
|
let persistedChatMetadata: unknown | null = null;
|
||||||
|
let referenceImagesUploaded = false;
|
||||||
|
|
||||||
return async (runOptions: BrowserRunOptions): Promise<BrowserRunResult> => {
|
return async (runOptions: BrowserRunOptions): Promise<BrowserRunResult> => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const log = runOptions.log;
|
const log = runOptions.log;
|
||||||
@@ -133,23 +249,38 @@ export function createGeminiWebExecutor(
|
|||||||
? Math.max(1_000, runOptions.config.timeoutMs)
|
? Math.max(1_000, runOptions.config.timeoutMs)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const generateVideoPath = resolveInvocationPath(geminiOptions.generateVideo);
|
||||||
|
|
||||||
const defaultTimeoutMs = geminiOptions.youtube
|
const defaultTimeoutMs = geminiOptions.youtube
|
||||||
? 240_000
|
? 240_000
|
||||||
: geminiOptions.generateImage || geminiOptions.editImage
|
: generateVideoPath
|
||||||
|
? 900_000
|
||||||
|
: geminiOptions.generateImage || geminiOptions.editImage
|
||||||
? 300_000
|
? 300_000
|
||||||
: 120_000;
|
: 120_000;
|
||||||
|
|
||||||
const timeoutMs = Math.min(configTimeout ?? defaultTimeoutMs, 600_000);
|
const timeoutCapMs = generateVideoPath ? 1_800_000 : 600_000;
|
||||||
|
const timeoutMs = Math.min(configTimeout ?? defaultTimeoutMs, timeoutCapMs);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
const keepSession = geminiOptions.keepSession === true;
|
||||||
|
|
||||||
const generateImagePath = resolveInvocationPath(geminiOptions.generateImage);
|
const generateImagePath = resolveInvocationPath(geminiOptions.generateImage);
|
||||||
const editImagePath = resolveInvocationPath(geminiOptions.editImage);
|
const editImagePath = resolveInvocationPath(geminiOptions.editImage);
|
||||||
const outputPath = resolveInvocationPath(geminiOptions.outputPath);
|
const outputPath = resolveInvocationPath(geminiOptions.outputPath);
|
||||||
const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path);
|
const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path);
|
||||||
|
const referenceImagePaths = normalizePathList(geminiOptions.referenceImages);
|
||||||
|
const requestFilePaths = dedupePaths(
|
||||||
|
keepSession ? attachmentPaths : [...referenceImagePaths, ...attachmentPaths],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (generateVideoPath && (generateImagePath || editImagePath)) {
|
||||||
|
throw new Error('Gemini web executor: generateVideo cannot be combined with generateImage/editImage options.');
|
||||||
|
}
|
||||||
|
|
||||||
let prompt = runOptions.prompt;
|
let prompt = runOptions.prompt;
|
||||||
if (geminiOptions.aspectRatio && (generateImagePath || editImagePath)) {
|
if (geminiOptions.aspectRatio && (generateImagePath || editImagePath || generateVideoPath)) {
|
||||||
prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`;
|
prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`;
|
||||||
}
|
}
|
||||||
if (geminiOptions.youtube) {
|
if (geminiOptions.youtube) {
|
||||||
@@ -158,29 +289,50 @@ export function createGeminiWebExecutor(
|
|||||||
if (generateImagePath && !editImagePath) {
|
if (generateImagePath && !editImagePath) {
|
||||||
prompt = `Generate an image: ${prompt}`;
|
prompt = `Generate an image: ${prompt}`;
|
||||||
}
|
}
|
||||||
|
if (generateVideoPath) {
|
||||||
|
prompt = `Generate a video: ${prompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
const model: GeminiWebModelId = resolveGeminiWebModel(runOptions.config?.desiredModel, log);
|
const model: GeminiWebModelId = resolveGeminiWebModel(runOptions.config?.desiredModel, log);
|
||||||
let response: GeminiWebResponse;
|
let response: GeminiWebResponse;
|
||||||
|
let videoSaveSummary: { saved: boolean; videoCount: number; outputPath: string } | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let chatMetadata: unknown = keepSession ? persistedChatMetadata : null;
|
||||||
|
|
||||||
|
if (keepSession && referenceImagePaths.length > 0 && !referenceImagesUploaded) {
|
||||||
|
const intro = await runGeminiWebWithFallback({
|
||||||
|
prompt: 'Here are reference images for future messages.',
|
||||||
|
files: referenceImagePaths,
|
||||||
|
model,
|
||||||
|
cookieMap,
|
||||||
|
chatMetadata,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
chatMetadata = intro.metadata;
|
||||||
|
persistedChatMetadata = intro.metadata;
|
||||||
|
referenceImagesUploaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (editImagePath) {
|
if (editImagePath) {
|
||||||
const intro = await runGeminiWebWithFallback({
|
const intro = await runGeminiWebWithFallback({
|
||||||
prompt: 'Here is an image to edit',
|
prompt: 'Here is an image to edit',
|
||||||
files: [editImagePath],
|
files: [editImagePath],
|
||||||
model,
|
model,
|
||||||
cookieMap,
|
cookieMap,
|
||||||
chatMetadata: null,
|
chatMetadata,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
const editPrompt = `Use image generation tool to ${prompt}`;
|
const editPrompt = `Use image generation tool to ${prompt}`;
|
||||||
const out = await runGeminiWebWithFallback({
|
const out = await runGeminiWebWithFallback({
|
||||||
prompt: editPrompt,
|
prompt: editPrompt,
|
||||||
files: attachmentPaths,
|
files: requestFilePaths,
|
||||||
model,
|
model,
|
||||||
cookieMap,
|
cookieMap,
|
||||||
chatMetadata: intro.metadata,
|
chatMetadata: intro.metadata,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
if (keepSession) persistedChatMetadata = out.metadata;
|
||||||
response = {
|
response = {
|
||||||
text: out.text ?? null,
|
text: out.text ?? null,
|
||||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||||
@@ -198,12 +350,13 @@ export function createGeminiWebExecutor(
|
|||||||
} else if (generateImagePath) {
|
} else if (generateImagePath) {
|
||||||
const out = await runGeminiWebWithFallback({
|
const out = await runGeminiWebWithFallback({
|
||||||
prompt,
|
prompt,
|
||||||
files: attachmentPaths,
|
files: requestFilePaths,
|
||||||
model,
|
model,
|
||||||
cookieMap,
|
cookieMap,
|
||||||
chatMetadata: null,
|
chatMetadata,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
if (keepSession) persistedChatMetadata = out.metadata;
|
||||||
response = {
|
response = {
|
||||||
text: out.text ?? null,
|
text: out.text ?? null,
|
||||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||||
@@ -216,15 +369,36 @@ export function createGeminiWebExecutor(
|
|||||||
if (!imageSave.saved) {
|
if (!imageSave.saved) {
|
||||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||||
}
|
}
|
||||||
|
} else if (generateVideoPath) {
|
||||||
|
const out = await runGeminiWebWithFallback({
|
||||||
|
prompt,
|
||||||
|
files: requestFilePaths,
|
||||||
|
model,
|
||||||
|
cookieMap,
|
||||||
|
chatMetadata,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (keepSession) persistedChatMetadata = out.metadata;
|
||||||
|
response = {
|
||||||
|
text: out.text ?? null,
|
||||||
|
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||||
|
has_images: false,
|
||||||
|
image_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedOutputPath = generateVideoPath ?? outputPath ?? 'generated.mp4';
|
||||||
|
const save = await saveFirstGeminiVideoFromOutput(out, cookieMap, resolvedOutputPath, controller.signal);
|
||||||
|
videoSaveSummary = { ...save, outputPath: resolvedOutputPath };
|
||||||
} else {
|
} else {
|
||||||
const out = await runGeminiWebWithFallback({
|
const out = await runGeminiWebWithFallback({
|
||||||
prompt,
|
prompt,
|
||||||
files: attachmentPaths,
|
files: requestFilePaths,
|
||||||
model,
|
model,
|
||||||
cookieMap,
|
cookieMap,
|
||||||
chatMetadata: null,
|
chatMetadata,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
if (keepSession) persistedChatMetadata = out.metadata;
|
||||||
response = {
|
response = {
|
||||||
text: out.text ?? null,
|
text: out.text ?? null,
|
||||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||||
@@ -247,6 +421,15 @@ export function createGeminiWebExecutor(
|
|||||||
const imagePath = generateImagePath || outputPath || 'generated.png';
|
const imagePath = generateImagePath || outputPath || 'generated.png';
|
||||||
answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`;
|
answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`;
|
||||||
}
|
}
|
||||||
|
if (videoSaveSummary) {
|
||||||
|
if (videoSaveSummary.saved) {
|
||||||
|
answerMarkdown += `\n\n*Generated ${videoSaveSummary.videoCount || 1} video(s). Saved to: ${videoSaveSummary.outputPath}*`;
|
||||||
|
} else if (/video_gen_chip/.test(answerMarkdown) || /video_gen_chip/.test(response.text ?? '')) {
|
||||||
|
answerMarkdown += '\n\n*Video generation is asynchronous. Check Gemini web UI to download the result.*';
|
||||||
|
} else {
|
||||||
|
answerMarkdown += '\n\n*No downloadable video URL found in Gemini response.*';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tookMs = Date.now() - startTime;
|
const tookMs = Date.now() - startTime;
|
||||||
log?.(`[gemini-web] Completed in ${tookMs}ms`);
|
log?.(`[gemini-web] Completed in ${tookMs}ms`);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
writeGeminiCookieMapToDisk,
|
writeGeminiCookieMapToDisk,
|
||||||
} from './cookie-store.js';
|
} from './cookie-store.js';
|
||||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||||
|
import { readSession, writeSession, listSessions } from './session-store.js';
|
||||||
|
|
||||||
function printUsage(exitCode = 0): never {
|
function printUsage(exitCode = 0): never {
|
||||||
const cookiePath = resolveGeminiWebCookiePath();
|
const cookiePath = resolveGeminiWebCookiePath();
|
||||||
@@ -21,12 +22,19 @@ function printUsage(exitCode = 0): never {
|
|||||||
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 --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
|
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:
|
Options:
|
||||||
-p, --prompt <text> Prompt text
|
-p, --prompt <text> Prompt text
|
||||||
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
|
--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)
|
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
|
||||||
--json Output JSON
|
--json Output JSON
|
||||||
--image [path] Generate an image and save it (default: ./generated.png)
|
--image [path] Generate an image and save it (default: ./generated.png)
|
||||||
|
--reference <files...> Reference images for vision input
|
||||||
|
--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
|
--login Only refresh cookies, then exit
|
||||||
--cookie-path <path> Cookie file path (default: ${cookiePath})
|
--cookie-path <path> Cookie file path (default: ${cookiePath})
|
||||||
--profile-dir <path> Chrome profile dir (default: ${profileDir})
|
--profile-dir <path> Chrome profile dir (default: ${profileDir})
|
||||||
@@ -75,6 +83,9 @@ function parseArgs(argv: string[]): {
|
|||||||
loginOnly?: boolean;
|
loginOnly?: boolean;
|
||||||
cookiePath?: string;
|
cookiePath?: string;
|
||||||
profileDir?: string;
|
profileDir?: string;
|
||||||
|
referenceImages?: string[];
|
||||||
|
sessionId?: string;
|
||||||
|
listSessions?: boolean;
|
||||||
} {
|
} {
|
||||||
const out: ReturnType<typeof parseArgs> = {};
|
const out: ReturnType<typeof parseArgs> = {};
|
||||||
const positional: string[] = [];
|
const positional: string[] = [];
|
||||||
@@ -157,6 +168,32 @@ function parseArgs(argv: string[]): {
|
|||||||
out.profileDir = arg.slice('--profile-dir='.length);
|
out.profileDir = arg.slice('--profile-dir='.length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (arg === '--reference' || arg === '--ref') {
|
||||||
|
out.referenceImages = [];
|
||||||
|
while (i + 1 < argv.length) {
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next && !next.startsWith('-')) {
|
||||||
|
out.referenceImages.push(next);
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === '--sessionId' || arg === '--session-id') {
|
||||||
|
out.sessionId = argv[i + 1] ?? '';
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg.startsWith('--sessionId=') || arg.startsWith('--session-id=')) {
|
||||||
|
out.sessionId = arg.split('=')[1] ?? '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === '--list-sessions') {
|
||||||
|
out.listSessions = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (arg.startsWith('-')) {
|
if (arg.startsWith('-')) {
|
||||||
throw new Error(`Unknown option: ${arg}`);
|
throw new Error(`Unknown option: ${arg}`);
|
||||||
@@ -178,6 +215,9 @@ function parseArgs(argv: string[]): {
|
|||||||
if (out.cookiePath === '') delete out.cookiePath;
|
if (out.cookiePath === '') delete out.cookiePath;
|
||||||
if (out.profileDir === '') delete out.profileDir;
|
if (out.profileDir === '') delete out.profileDir;
|
||||||
if (out.promptFiles?.length === 0) delete out.promptFiles;
|
if (out.promptFiles?.length === 0) delete out.promptFiles;
|
||||||
|
if (out.referenceImages?.length === 0) delete out.referenceImages;
|
||||||
|
if (out.sessionId != null) out.sessionId = out.sessionId.trim();
|
||||||
|
if (out.sessionId === '') delete out.sessionId;
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -186,7 +226,7 @@ async function isCookieMapValid(cookieMap: Record<string, string>): Promise<bool
|
|||||||
if (!hasRequiredGeminiCookies(cookieMap)) return false;
|
if (!hasRequiredGeminiCookies(cookieMap)) return false;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
const timer = setTimeout(() => controller.abort(), 30_000);
|
||||||
try {
|
try {
|
||||||
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
||||||
return true;
|
return true;
|
||||||
@@ -201,7 +241,7 @@ async function ensureGeminiCookieMap(options: {
|
|||||||
cookiePath: string;
|
cookiePath: string;
|
||||||
profileDir: string;
|
profileDir: string;
|
||||||
}): Promise<Record<string, string>> {
|
}): Promise<Record<string, string>> {
|
||||||
const log = (msg: string) => console.log(msg);
|
const log = (msg: string) => console.error(msg);
|
||||||
|
|
||||||
let cookieMap = await readGeminiCookieMapFromDisk({ cookiePath: options.cookiePath, log });
|
let cookieMap = await readGeminiCookieMapFromDisk({ cookiePath: options.cookiePath, log });
|
||||||
if (await isCookieMapValid(cookieMap)) return cookieMap;
|
if (await isCookieMapValid(cookieMap)) return cookieMap;
|
||||||
@@ -251,85 +291,63 @@ async function main(): Promise<void> {
|
|||||||
const cookiePath = args.cookiePath ?? resolveGeminiWebCookiePath();
|
const cookiePath = args.cookiePath ?? resolveGeminiWebCookiePath();
|
||||||
const profileDir = args.profileDir ?? resolveGeminiWebChromeProfileDir();
|
const profileDir = args.profileDir ?? resolveGeminiWebChromeProfileDir();
|
||||||
|
|
||||||
|
if (args.listSessions) {
|
||||||
|
const sessions = await listSessions();
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log('No saved sessions.');
|
||||||
|
} else {
|
||||||
|
for (const { id, updatedAt } of sessions) {
|
||||||
|
console.log(`${id}\t${updatedAt}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (args.loginOnly) {
|
if (args.loginOnly) {
|
||||||
await ensureGeminiCookieMap({ cookiePath, profileDir });
|
await ensureGeminiCookieMap({ cookiePath, profileDir });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptFromStdin = await readPromptFromStdin();
|
|
||||||
const promptFromFiles = args.promptFiles ? readPromptFiles(args.promptFiles) : null;
|
const promptFromFiles = args.promptFiles ? readPromptFiles(args.promptFiles) : null;
|
||||||
const prompt = promptFromFiles || args.prompt || promptFromStdin;
|
const promptFromArgs = promptFromFiles || args.prompt;
|
||||||
|
const prompt = promptFromArgs || (await readPromptFromStdin());
|
||||||
if (!prompt) printUsage(1);
|
if (!prompt) printUsage(1);
|
||||||
|
|
||||||
|
const sessionData = args.sessionId ? await readSession(args.sessionId) : null;
|
||||||
|
const chatMetadata = sessionData?.metadata ?? null;
|
||||||
|
|
||||||
let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir });
|
let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir });
|
||||||
|
|
||||||
const desiredModel = resolveModel(args.model || 'gemini-3-pro');
|
const desiredModel = resolveModel(args.model || 'gemini-3-pro');
|
||||||
const imagePath = resolveImageOutputPath(args.imagePath);
|
const imagePath = resolveImageOutputPath(args.imagePath);
|
||||||
|
const referenceImages = (args.referenceImages ?? []).map((p) =>
|
||||||
|
path.isAbsolute(p) ? p : path.resolve(process.cwd(), p),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const effectivePrompt = imagePath ? `Generate an image: ${prompt}` : prompt;
|
const controller = new AbortController();
|
||||||
const out = await runGeminiWebWithFallback({
|
const timeoutMs = imagePath ? 300_000 : 120_000;
|
||||||
prompt: effectivePrompt,
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
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;
|
|
||||||
} 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) });
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const effectivePrompt = imagePath ? `Generate an image: ${prompt}` : prompt;
|
||||||
const out = await runGeminiWebWithFallback({
|
const out = await runGeminiWebWithFallback({
|
||||||
prompt: imagePath ? `Generate an image: ${prompt}` : prompt,
|
prompt: effectivePrompt,
|
||||||
files: [],
|
files: referenceImages,
|
||||||
model: desiredModel,
|
model: desiredModel,
|
||||||
cookieMap,
|
cookieMap,
|
||||||
chatMetadata: null,
|
chatMetadata,
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (args.sessionId && out.metadata) {
|
||||||
|
await writeSession(args.sessionId, out.metadata, prompt, out.text ?? '', out.errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
let imageSaved = false;
|
let imageSaved = false;
|
||||||
let imageCount = 0;
|
let imageCount = 0;
|
||||||
if (imagePath) {
|
if (imagePath) {
|
||||||
const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath);
|
const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath, controller.signal);
|
||||||
imageSaved = save.saved;
|
imageSaved = save.saved;
|
||||||
imageCount = save.imageCount;
|
imageCount = save.imageCount;
|
||||||
if (!imageSaved) {
|
if (!imageSaved) {
|
||||||
@@ -338,13 +356,8 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.json) {
|
if (args.json) {
|
||||||
process.stdout.write(
|
const jsonOut = { ...out, ...(imagePath && { imageSaved, imageCount, imagePath }), ...(args.sessionId && { sessionId: args.sessionId }) };
|
||||||
`${JSON.stringify(
|
process.stdout.write(`${JSON.stringify(jsonOut, null, 2)}\n`);
|
||||||
imagePath ? { ...out, imageSaved, imageCount, imagePath } : out,
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
);
|
|
||||||
if (out.errorMessage) process.exit(1);
|
if (out.errorMessage) process.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -359,6 +372,67 @@ async function main(): Promise<void> {
|
|||||||
process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`);
|
process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
} 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.error(m) });
|
||||||
|
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log: (m) => console.error(m) });
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutMs = imagePath ? 300_000 : 120_000;
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = await runGeminiWebWithFallback({
|
||||||
|
prompt: imagePath ? `Generate an image: ${prompt}` : prompt,
|
||||||
|
files: referenceImages,
|
||||||
|
model: desiredModel,
|
||||||
|
cookieMap,
|
||||||
|
chatMetadata,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.sessionId && out.metadata) {
|
||||||
|
await writeSession(args.sessionId, out.metadata, prompt, out.text ?? '', out.errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageSaved = false;
|
||||||
|
let imageCount = 0;
|
||||||
|
if (imagePath) {
|
||||||
|
const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath, controller.signal);
|
||||||
|
imageSaved = save.saved;
|
||||||
|
imageCount = save.imageCount;
|
||||||
|
if (!imageSaved) {
|
||||||
|
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.json) {
|
||||||
|
const jsonOut = { ...out, ...(imagePath && { imageSaved, imageCount, imagePath }), ...(args.sessionId && { sessionId: args.sessionId }) };
|
||||||
|
process.stdout.write(`${JSON.stringify(jsonOut, 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;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -34,3 +34,12 @@ export function resolveGeminiWebChromeProfileDir(): string {
|
|||||||
if (override) return path.resolve(override);
|
if (override) return path.resolve(override);
|
||||||
return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME);
|
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,90 @@
|
|||||||
|
import { mkdir, readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { resolveGeminiWebSessionsDir, resolveGeminiWebSessionPath } from './paths.js';
|
||||||
|
|
||||||
|
export interface SessionMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
id: string;
|
||||||
|
metadata: unknown;
|
||||||
|
messages: SessionMessage[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionListItem {
|
||||||
|
id: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSession(id: string): Promise<SessionData | null> {
|
||||||
|
const sessionPath = resolveGeminiWebSessionPath(id);
|
||||||
|
try {
|
||||||
|
const content = await readFile(sessionPath, 'utf8');
|
||||||
|
return JSON.parse(content) as SessionData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSession(
|
||||||
|
id: string,
|
||||||
|
metadata: unknown,
|
||||||
|
userMessage: string,
|
||||||
|
assistantMessage: string,
|
||||||
|
error?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionPath = resolveGeminiWebSessionPath(id);
|
||||||
|
const sessionsDir = resolveGeminiWebSessionsDir();
|
||||||
|
await mkdir(sessionsDir, { recursive: true });
|
||||||
|
|
||||||
|
const existing = await readSession(id);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const newMessages: SessionMessage[] = [
|
||||||
|
{ role: 'user', content: userMessage, timestamp: now },
|
||||||
|
{ role: 'assistant', content: assistantMessage, timestamp: now, ...(error && { error }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const data: SessionData = {
|
||||||
|
id,
|
||||||
|
metadata,
|
||||||
|
messages: [...(existing?.messages ?? []), ...newMessages],
|
||||||
|
createdAt: existing?.createdAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await writeFile(sessionPath, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessions(limit = 100): Promise<SessionListItem[]> {
|
||||||
|
const sessionsDir = resolveGeminiWebSessionsDir();
|
||||||
|
try {
|
||||||
|
const files = await readdir(sessionsDir);
|
||||||
|
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
||||||
|
|
||||||
|
const items: { id: string; updatedAt: string; mtime: number }[] = [];
|
||||||
|
for (const file of jsonFiles) {
|
||||||
|
const filePath = path.join(sessionsDir, file);
|
||||||
|
try {
|
||||||
|
const stats = await stat(filePath);
|
||||||
|
items.push({
|
||||||
|
id: file.slice(0, -5),
|
||||||
|
updatedAt: stats.mtime.toISOString(),
|
||||||
|
mtime: stats.mtime.getTime(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
return items.slice(0, limit).map(({ id, updatedAt }) => ({ id, updatedAt }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,18 @@ export interface GeminiWebOptions {
|
|||||||
youtube?: string;
|
youtube?: string;
|
||||||
generateImage?: string;
|
generateImage?: string;
|
||||||
editImage?: string;
|
editImage?: string;
|
||||||
|
generateVideo?: string;
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
showThoughts?: boolean;
|
showThoughts?: boolean;
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
|
/**
|
||||||
|
* One or more local image paths to upload as persistent reference images.
|
||||||
|
* - If `keepSession` is enabled, they are uploaded once per executor session.
|
||||||
|
* - Otherwise, they are attached to each request.
|
||||||
|
*/
|
||||||
|
referenceImages?: string | string[];
|
||||||
|
/** Preserve Gemini chat metadata to continue multi-turn conversations within the same executor instance. */
|
||||||
|
keepSession?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeminiWebResponse {
|
export interface GeminiWebResponse {
|
||||||
|
|||||||
@@ -232,14 +232,21 @@ Style notes: [specific style characteristics to emphasize]
|
|||||||
|
|
||||||
### Step 5: Generate Images
|
### Step 5: Generate Images
|
||||||
|
|
||||||
|
**Session Management**:
|
||||||
|
If the image generation skill supports `--sessionId`:
|
||||||
|
1. Generate a unique session ID at the start (e.g., `slides-{topic-slug}-{timestamp}`)
|
||||||
|
2. Use the same session ID for all slides
|
||||||
|
3. This ensures visual consistency (color scheme, style, typography) across all slides
|
||||||
|
|
||||||
For each slide, generate using:
|
For each slide, generate using:
|
||||||
|
|
||||||
```bash
|
```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
|
# With session support
|
||||||
|
/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 --sessionId slides-topic-20260117
|
||||||
```
|
```
|
||||||
|
|
||||||
Generation flow:
|
Generation flow:
|
||||||
1. Generate images sequentially
|
1. Generate images sequentially with the same session ID
|
||||||
2. After each image, output progress: "Generated X/N"
|
2. After each image, output progress: "Generated X/N"
|
||||||
3. On failure, auto-retry once
|
3. On failure, auto-retry once
|
||||||
4. If retry fails, log reason, continue to next
|
4. If retry fails, log reason, continue to next
|
||||||
|
|||||||
@@ -261,8 +261,14 @@ Style notes: [style-specific characteristics]
|
|||||||
1. Check available image generation skills
|
1. Check available image generation skills
|
||||||
2. If multiple skills available, ask user to choose
|
2. If multiple skills available, ask user to choose
|
||||||
|
|
||||||
|
**Session Management**:
|
||||||
|
If the image generation skill supports `--sessionId`:
|
||||||
|
1. Generate a unique session ID at the start (e.g., `xhs-{topic-slug}-{timestamp}`)
|
||||||
|
2. Use the same session ID for all images in the series
|
||||||
|
3. This ensures style consistency across all generated images
|
||||||
|
|
||||||
**Generation Flow**:
|
**Generation Flow**:
|
||||||
1. Call selected image generation skill with prompt file and output path
|
1. Call selected image generation skill with prompt file, output path, and session ID
|
||||||
2. Confirm generation success
|
2. Confirm generation success
|
||||||
3. Report progress: "Generated X/N"
|
3. Report progress: "Generated X/N"
|
||||||
4. Continue to next
|
4. Continue to next
|
||||||
|
|||||||
Reference in New Issue
Block a user