niahere 0.2.12 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/package.json +1 -1
- package/skills/github-link-repo-explorer/SKILL.md +104 -0
- package/skills/image-generation/SKILL.md +121 -0
- package/skills/image-generation/scripts/generate_image.py +401 -0
- package/skills/llms-txt/SKILL.md +141 -0
- package/skills/pr-reviewer/SKILL.md +187 -0
- package/skills/render-cli/SKILL.md +128 -0
- package/src/chat/engine.ts +73 -10
- package/src/chat/repl.ts +176 -11
- package/src/cli/index.ts +112 -5
- package/src/db/models/session.ts +38 -0
- package/src/types/engine.ts +2 -1
package/README.md
CHANGED
|
@@ -42,6 +42,10 @@ nia db setup — install PostgreSQL + create database + migrat
|
|
|
42
42
|
nia db migrate — run database migrations
|
|
43
43
|
nia db status — check database connection
|
|
44
44
|
|
|
45
|
+
nia config list — show all config
|
|
46
|
+
nia config get <key> — get a config value (dot notation supported)
|
|
47
|
+
nia config set <key> <value> — set a config value
|
|
48
|
+
|
|
45
49
|
nia channels — show channel status (on/off)
|
|
46
50
|
nia channels on / off — enable/disable channels
|
|
47
51
|
```
|
|
@@ -76,7 +80,7 @@ All config and data lives in `~/.niahere/`:
|
|
|
76
80
|
|
|
77
81
|
```
|
|
78
82
|
~/.niahere/
|
|
79
|
-
config.yaml — database, channels, model, timezone, active hours,
|
|
83
|
+
config.yaml — database, channels, model, timezone, active hours, API keys
|
|
80
84
|
self/
|
|
81
85
|
identity.md — agent personality and voice
|
|
82
86
|
owner.md — who runs this agent
|
|
@@ -94,7 +98,8 @@ All config and data lives in `~/.niahere/`:
|
|
|
94
98
|
- [Bun](https://bun.sh) runtime (auto-installed if missing)
|
|
95
99
|
- PostgreSQL (`nia db setup` handles installation)
|
|
96
100
|
- Claude API access (via `@anthropic-ai/claude-agent-sdk`)
|
|
97
|
-
- Gemini API key (optional, for image generation)
|
|
101
|
+
- Gemini API key (optional, for image generation — `nia config set gemini_api_key ...`)
|
|
102
|
+
- OpenAI API key (optional, for image generation — `nia config set openai_api_key ...`)
|
|
98
103
|
|
|
99
104
|
## Author
|
|
100
105
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: github-link-repo-explorer
|
|
3
|
+
description: Explore a GitHub repository from a link by cloning locally first, then inspecting files and history. Use when a task includes a GitHub repo URL or owner/repo and code-level analysis is needed. Prefer local clone into /tmp before using web pages.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## GitHub Link Repo Explorer
|
|
7
|
+
|
|
8
|
+
Explore repositories from GitHub links with a local-first workflow.
|
|
9
|
+
|
|
10
|
+
## Core Rule
|
|
11
|
+
|
|
12
|
+
Always try local clone before web browsing.
|
|
13
|
+
|
|
14
|
+
- Clone target: `/tmp/<repo-name>`
|
|
15
|
+
- Always run clone/update commands from `/tmp` (`cd /tmp` first).
|
|
16
|
+
- If directory exists, update it (`git fetch --all --prune`) or reclone if corrupted.
|
|
17
|
+
- Use web fallback only when clone/auth/network access fails.
|
|
18
|
+
- Do not start with web exploration when a local clone attempt is possible.
|
|
19
|
+
|
|
20
|
+
## Inputs
|
|
21
|
+
|
|
22
|
+
Accept any of:
|
|
23
|
+
- full URL: `https://github.com/<owner>/<repo>`
|
|
24
|
+
- SSH URL: `git@github.com:<owner>/<repo>.git`
|
|
25
|
+
- short form: `<owner>/<repo>`
|
|
26
|
+
|
|
27
|
+
Normalize input to:
|
|
28
|
+
- `owner`
|
|
29
|
+
- `repo`
|
|
30
|
+
- `clone_url`
|
|
31
|
+
- `local_path=/tmp/<repo>`
|
|
32
|
+
|
|
33
|
+
## Workflow
|
|
34
|
+
|
|
35
|
+
### 1. Prepare local checkout
|
|
36
|
+
|
|
37
|
+
1. Parse owner/repo from input.
|
|
38
|
+
2. Set `local_path` to `/tmp/<repo>`.
|
|
39
|
+
3. Change working directory to `/tmp`.
|
|
40
|
+
4. Clone if missing:
|
|
41
|
+
- `cd /tmp && git clone --filter=blob:none <clone_url> <repo>`
|
|
42
|
+
5. If present, refresh:
|
|
43
|
+
- `cd /tmp/<repo> && git fetch --all --prune`
|
|
44
|
+
|
|
45
|
+
### 2. Establish repository context
|
|
46
|
+
|
|
47
|
+
Run quick context commands:
|
|
48
|
+
- `git -C /tmp/<repo> remote -v`
|
|
49
|
+
- `git -C /tmp/<repo> branch -a`
|
|
50
|
+
- `git -C /tmp/<repo> log --oneline -n 20`
|
|
51
|
+
- `rg --files /tmp/<repo>` (or equivalent)
|
|
52
|
+
|
|
53
|
+
Identify:
|
|
54
|
+
- default branch
|
|
55
|
+
- primary language and build system
|
|
56
|
+
- key entry points and docs (`README`, `docs/`, configs)
|
|
57
|
+
|
|
58
|
+
### 3. Perform the requested analysis
|
|
59
|
+
|
|
60
|
+
Prefer direct file inspection over assumptions:
|
|
61
|
+
- locate with `rg`
|
|
62
|
+
- read targeted files only
|
|
63
|
+
- cite exact file paths and relevant lines
|
|
64
|
+
- run project checks/tests only if needed and safe
|
|
65
|
+
|
|
66
|
+
### 4. Keep output actionable
|
|
67
|
+
|
|
68
|
+
Return:
|
|
69
|
+
- what was inspected
|
|
70
|
+
- findings with file references
|
|
71
|
+
- unresolved gaps
|
|
72
|
+
- next concrete checks
|
|
73
|
+
|
|
74
|
+
## Fallbacks
|
|
75
|
+
|
|
76
|
+
If clone fails:
|
|
77
|
+
1. Report exact failure reason (auth, not found, network, rate limits).
|
|
78
|
+
2. Retry clone from `/tmp` using alternate URL form (HTTPS vs SSH) if appropriate.
|
|
79
|
+
3. If still blocked, use web exploration of GitHub pages for limited analysis.
|
|
80
|
+
4. Clearly mark web-derived conclusions as lower confidence than local checkout results.
|
|
81
|
+
|
|
82
|
+
## Safety and Efficiency
|
|
83
|
+
|
|
84
|
+
- Do not modify the cloned repository unless explicitly requested.
|
|
85
|
+
- Avoid broad recursive reads when targeted search is sufficient.
|
|
86
|
+
- Remove or reuse stale `/tmp/<repo>` directories carefully.
|
|
87
|
+
- Prefer shallow/sparse strategies when full history is unnecessary.
|
|
88
|
+
|
|
89
|
+
## Quick Command Template
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
REPO_INPUT="https://github.com/owner/repo"
|
|
93
|
+
OWNER_REPO="$(printf '%s' "$REPO_INPUT" | sed -E 's#(git@github.com:|https://github.com/)##; s#\.git$##')"
|
|
94
|
+
REPO_NAME="${OWNER_REPO##*/}"
|
|
95
|
+
LOCAL_PATH="/tmp/$REPO_NAME"
|
|
96
|
+
|
|
97
|
+
cd /tmp
|
|
98
|
+
if [ ! -d "$LOCAL_PATH/.git" ]; then
|
|
99
|
+
git clone --filter=blob:none "https://github.com/$OWNER_REPO.git" "$REPO_NAME"
|
|
100
|
+
else
|
|
101
|
+
cd "$LOCAL_PATH"
|
|
102
|
+
git fetch --all --prune
|
|
103
|
+
fi
|
|
104
|
+
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: image-generation
|
|
3
|
+
description: Generate images using OpenAI (default) or Gemini. Use when asked to generate, create, or make images. Supports text-to-image and image editing with reference images.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Image Generation
|
|
7
|
+
|
|
8
|
+
General-purpose image generation skill supporting **OpenAI** (default) and **Gemini**.
|
|
9
|
+
|
|
10
|
+
## Script
|
|
11
|
+
|
|
12
|
+
`scripts/generate_image.py` — zero external dependencies (stdlib only).
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
API keys in `~/.niahere/config.yaml`:
|
|
17
|
+
```yaml
|
|
18
|
+
openai_api_key: sk-...
|
|
19
|
+
gemini_api_key: AIza...
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or set via CLI or environment variables:
|
|
23
|
+
```bash
|
|
24
|
+
nia config set openai_api_key sk-...
|
|
25
|
+
nia config set gemini_api_key AIza...
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Keys are resolved in order: `--api-key` flag > env var (`$OPENAI_API_KEY` / `$GEMINI_API_KEY`) > `config.yaml`.
|
|
29
|
+
|
|
30
|
+
## Providers & Models
|
|
31
|
+
|
|
32
|
+
| Provider | Default Model | Alternatives |
|
|
33
|
+
|----------|--------------|--------------|
|
|
34
|
+
| **OpenAI** (default) | `gpt-image-1.5` | `gpt-image-1`, `gpt-image-1-mini` |
|
|
35
|
+
| **Gemini** | `gemini-3.1-flash-image-preview` | `gemini-3-pro-image-preview`, `gemini-2.5-flash-image` |
|
|
36
|
+
|
|
37
|
+
Note: `dall-e-2` and `dall-e-3` are deprecated (EOL May 2026). Use `gpt-image-1.5` instead.
|
|
38
|
+
|
|
39
|
+
## Quick Reference
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
SCRIPT="/Users/aman/.shared/skills/image-generation/scripts/generate_image.py"
|
|
43
|
+
|
|
44
|
+
# Basic generation (OpenAI, default)
|
|
45
|
+
python3 $SCRIPT --prompt "A sunset over mountains"
|
|
46
|
+
|
|
47
|
+
# High quality
|
|
48
|
+
python3 $SCRIPT --prompt "Oil painting of a forest" --quality high
|
|
49
|
+
|
|
50
|
+
# With aspect ratio
|
|
51
|
+
python3 $SCRIPT --prompt "Portrait photo" --aspect-ratio 3:4
|
|
52
|
+
|
|
53
|
+
# With reference image (OpenAI edit mode)
|
|
54
|
+
python3 $SCRIPT --prompt "Add a rainbow to this scene" --reference photo.png
|
|
55
|
+
|
|
56
|
+
# Gemini provider
|
|
57
|
+
python3 $SCRIPT --provider gemini --prompt "Watercolor sunset" --aspect-ratio 16:9
|
|
58
|
+
|
|
59
|
+
# Gemini with reference
|
|
60
|
+
python3 $SCRIPT --provider gemini --reference face.png \
|
|
61
|
+
--prompt "Same person sitting in a cafe, natural lighting" --aspect-ratio 9:16
|
|
62
|
+
|
|
63
|
+
# Custom output location
|
|
64
|
+
python3 $SCRIPT --prompt "A cat" --output /path/to/output/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Aspect Ratios
|
|
68
|
+
|
|
69
|
+
| Use Case | Ratio | Notes |
|
|
70
|
+
|----------|-------|-------|
|
|
71
|
+
| Square / social | `1:1` | Default |
|
|
72
|
+
| Portrait | `3:4` or `2:3` | Vertical |
|
|
73
|
+
| Landscape | `4:3` or `16:9` | Wide |
|
|
74
|
+
| Phone / story | `9:16` | Vertical tall |
|
|
75
|
+
| Ultrawide | `21:9` | Cinematic |
|
|
76
|
+
|
|
77
|
+
OpenAI maps ratios to closest supported size (`1024x1024`, `1024x1536`, `1536x1024`).
|
|
78
|
+
|
|
79
|
+
## OpenAI Quality (gpt-image-1 only)
|
|
80
|
+
|
|
81
|
+
- `auto` (default) — let the model decide
|
|
82
|
+
- `high` — best quality, slower
|
|
83
|
+
- `medium` — balanced
|
|
84
|
+
- `low` — fastest, cheapest
|
|
85
|
+
|
|
86
|
+
## Structured Prompt Tips
|
|
87
|
+
|
|
88
|
+
For photorealistic results, use structured JSON prompts covering separate concerns:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"subject": "description of the main subject, pose, expression",
|
|
93
|
+
"clothing": "specific garments, colors, fabrics",
|
|
94
|
+
"camera": "lens mm, aperture, shot type, angle",
|
|
95
|
+
"lighting": "source, direction, quality, shadows",
|
|
96
|
+
"environment": "setting, background elements, atmosphere",
|
|
97
|
+
"color_grading": "palette, contrast, mood",
|
|
98
|
+
"technical": "style keywords — photorealistic, 8k, hyper-detailed"
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Key principles:
|
|
103
|
+
1. **Separate concerns** — one aspect per block
|
|
104
|
+
2. **Specify camera** — lens mm and aperture drive realism
|
|
105
|
+
3. **Light direction** — "soft light from upper right" > "good lighting"
|
|
106
|
+
4. **Material callouts** — "ribbed knit", "satin", "denim" > "nice clothes"
|
|
107
|
+
5. **Avoid over-constraining** — 2-3 adjectives per block max
|
|
108
|
+
|
|
109
|
+
## Provider Selection Guide
|
|
110
|
+
|
|
111
|
+
| Need | Use |
|
|
112
|
+
|------|-----|
|
|
113
|
+
| General image gen, highest quality | OpenAI `gpt-image-1.5` |
|
|
114
|
+
| Budget-friendly | OpenAI `gpt-image-1-mini` |
|
|
115
|
+
| Reference-based identity (same face) | Gemini (better at preserving identity from reference) |
|
|
116
|
+
| Image editing / inpainting | OpenAI edit mode (`--reference`) |
|
|
117
|
+
| Free tier / no OpenAI key | Gemini |
|
|
118
|
+
|
|
119
|
+
## Combining with Bella
|
|
120
|
+
|
|
121
|
+
For Bella-specific image generation (identity-locked, reference-based), use the bella skill's `bella-image-generation.md` workflow instead. This skill is for general-purpose image generation without persona constraints.
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
General-purpose image generation using OpenAI (default) or Gemini.
|
|
4
|
+
|
|
5
|
+
Supports:
|
|
6
|
+
- OpenAI: gpt-image-1.5 (default), gpt-image-1, gpt-image-1-mini
|
|
7
|
+
- Gemini: gemini-3.1-flash-image-preview (default), gemini-3-pro-image-preview, gemini-2.5-flash-image
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# OpenAI (default)
|
|
11
|
+
python3 generate_image.py --prompt "A sunset over mountains"
|
|
12
|
+
|
|
13
|
+
# OpenAI with reference image (edit mode)
|
|
14
|
+
python3 generate_image.py --prompt "Add a hot air balloon" --reference photo.png
|
|
15
|
+
|
|
16
|
+
# Gemini
|
|
17
|
+
python3 generate_image.py --provider gemini --prompt "A sunset over mountains"
|
|
18
|
+
|
|
19
|
+
# Gemini with reference image
|
|
20
|
+
python3 generate_image.py --provider gemini --prompt "Same person in a cafe" --reference face.png
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import base64
|
|
27
|
+
import json
|
|
28
|
+
import mimetypes
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
37
|
+
SKILL_DIR = SCRIPT_DIR.parent
|
|
38
|
+
NIA_HOME = Path(os.environ.get("NIA_HOME", Path.home() / ".niahere"))
|
|
39
|
+
NIA_CONFIG = NIA_HOME / "config.yaml"
|
|
40
|
+
TIMESTAMP_FORMAT = "%Y%m%d_%H%M%S"
|
|
41
|
+
|
|
42
|
+
# --- Provider defaults ---
|
|
43
|
+
OPENAI_DEFAULT_MODEL = "gpt-image-1.5"
|
|
44
|
+
GEMINI_DEFAULT_MODEL = "gemini-3.1-flash-image-preview"
|
|
45
|
+
|
|
46
|
+
DEFAULT_ASPECT_RATIO = "1:1"
|
|
47
|
+
ALLOWED_ASPECT_RATIOS = (
|
|
48
|
+
"1:1", "3:4", "4:3", "9:16", "16:9",
|
|
49
|
+
"2:3", "3:2", "4:5", "5:4", "21:9",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# OpenAI size mappings (closest match for aspect ratio)
|
|
53
|
+
OPENAI_SIZE_MAP = {
|
|
54
|
+
"1:1": "1024x1024",
|
|
55
|
+
"3:4": "1024x1536",
|
|
56
|
+
"4:3": "1536x1024",
|
|
57
|
+
"9:16": "1024x1536",
|
|
58
|
+
"16:9": "1536x1024",
|
|
59
|
+
"2:3": "1024x1536",
|
|
60
|
+
"3:2": "1536x1024",
|
|
61
|
+
"4:5": "1024x1536",
|
|
62
|
+
"5:4": "1536x1024",
|
|
63
|
+
"21:9": "1536x1024",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# OpenAI quality options
|
|
67
|
+
OPENAI_QUALITIES = ("auto", "high", "medium", "low")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def safe_mime(path: str) -> str:
|
|
71
|
+
mime, _ = mimetypes.guess_type(path)
|
|
72
|
+
return mime or "image/png"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def encode_file(path: str) -> str:
|
|
76
|
+
with open(path, "rb") as f:
|
|
77
|
+
return base64.b64encode(f.read()).decode("utf-8")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_output_path(output: str | None, ext: str = ".png") -> Path:
|
|
81
|
+
if output:
|
|
82
|
+
out = Path(output).expanduser()
|
|
83
|
+
if out.suffix:
|
|
84
|
+
return out
|
|
85
|
+
return out / f"image_{time.strftime(TIMESTAMP_FORMAT)}{ext}"
|
|
86
|
+
return Path(f"/tmp/image_{time.strftime(TIMESTAMP_FORMAT)}{ext}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_config_key(key: str) -> str:
|
|
90
|
+
"""Read a key from ~/.niahere/config.yaml."""
|
|
91
|
+
if not NIA_CONFIG.is_file():
|
|
92
|
+
return ""
|
|
93
|
+
try:
|
|
94
|
+
import importlib
|
|
95
|
+
yaml = importlib.import_module("yaml")
|
|
96
|
+
with NIA_CONFIG.open("r") as f:
|
|
97
|
+
config = yaml.safe_load(f)
|
|
98
|
+
if config and isinstance(config, dict):
|
|
99
|
+
return config.get(key, "") or ""
|
|
100
|
+
except Exception:
|
|
101
|
+
for line in NIA_CONFIG.read_text().splitlines():
|
|
102
|
+
if line.startswith(f"{key}:"):
|
|
103
|
+
val = line.split(":", 1)[1].strip().strip("'\"")
|
|
104
|
+
return val
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_api_key(provider: str, cli_key: str | None) -> str:
|
|
109
|
+
if cli_key:
|
|
110
|
+
return cli_key
|
|
111
|
+
|
|
112
|
+
if provider == "openai":
|
|
113
|
+
return (
|
|
114
|
+
os.environ.get("OPENAI_API_KEY", "")
|
|
115
|
+
or read_config_key("openai_api_key")
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
return (
|
|
119
|
+
os.environ.get("GEMINI_API_KEY", "")
|
|
120
|
+
or os.environ.get("GOOGLE_API_KEY", "")
|
|
121
|
+
or read_config_key("gemini_api_key")
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- OpenAI Generation ---
|
|
126
|
+
|
|
127
|
+
def generate_openai(
|
|
128
|
+
api_key: str,
|
|
129
|
+
prompt: str,
|
|
130
|
+
model: str,
|
|
131
|
+
size: str,
|
|
132
|
+
quality: str,
|
|
133
|
+
reference_path: str | None = None,
|
|
134
|
+
n: int = 1,
|
|
135
|
+
) -> tuple[bytes, str]:
|
|
136
|
+
"""Generate image via OpenAI Images API."""
|
|
137
|
+
if reference_path and Path(reference_path).is_file():
|
|
138
|
+
return _openai_edit(api_key, prompt, reference_path, model, size, quality, n)
|
|
139
|
+
return _openai_generate(api_key, prompt, model, size, quality, n)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _openai_generate(
|
|
143
|
+
api_key: str, prompt: str, model: str, size: str, quality: str, n: int
|
|
144
|
+
) -> tuple[bytes, str]:
|
|
145
|
+
url = "https://api.openai.com/v1/images/generations"
|
|
146
|
+
payload: dict = {
|
|
147
|
+
"model": model,
|
|
148
|
+
"prompt": prompt,
|
|
149
|
+
"n": n,
|
|
150
|
+
"size": size,
|
|
151
|
+
"response_format": "b64_json",
|
|
152
|
+
}
|
|
153
|
+
if model == "gpt-image-1":
|
|
154
|
+
payload["quality"] = quality
|
|
155
|
+
|
|
156
|
+
req = urllib.request.Request(
|
|
157
|
+
url=url,
|
|
158
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
159
|
+
method="POST",
|
|
160
|
+
headers={
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
"Authorization": f"Bearer {api_key}",
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
return _openai_request(req)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _openai_edit(
|
|
169
|
+
api_key: str, prompt: str, reference_path: str, model: str, size: str,
|
|
170
|
+
quality: str, n: int
|
|
171
|
+
) -> tuple[bytes, str]:
|
|
172
|
+
"""Use OpenAI images/edits endpoint with a reference image."""
|
|
173
|
+
import io
|
|
174
|
+
|
|
175
|
+
boundary = f"----PythonBoundary{int(time.time() * 1000)}"
|
|
176
|
+
body = io.BytesIO()
|
|
177
|
+
|
|
178
|
+
def add_field(name: str, value: str) -> None:
|
|
179
|
+
body.write(f"--{boundary}\r\n".encode())
|
|
180
|
+
body.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
|
|
181
|
+
body.write(f"{value}\r\n".encode())
|
|
182
|
+
|
|
183
|
+
def add_file(name: str, filepath: str) -> None:
|
|
184
|
+
filename = Path(filepath).name
|
|
185
|
+
mime = safe_mime(filepath)
|
|
186
|
+
body.write(f"--{boundary}\r\n".encode())
|
|
187
|
+
body.write(
|
|
188
|
+
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode()
|
|
189
|
+
)
|
|
190
|
+
body.write(f"Content-Type: {mime}\r\n\r\n".encode())
|
|
191
|
+
with open(filepath, "rb") as f:
|
|
192
|
+
body.write(f.read())
|
|
193
|
+
body.write(b"\r\n")
|
|
194
|
+
|
|
195
|
+
add_file("image[]", reference_path)
|
|
196
|
+
add_field("prompt", prompt)
|
|
197
|
+
add_field("model", model)
|
|
198
|
+
add_field("n", str(n))
|
|
199
|
+
add_field("size", size)
|
|
200
|
+
if model == "gpt-image-1":
|
|
201
|
+
add_field("quality", quality)
|
|
202
|
+
body.write(f"--{boundary}--\r\n".encode())
|
|
203
|
+
|
|
204
|
+
url = "https://api.openai.com/v1/images/edits"
|
|
205
|
+
req = urllib.request.Request(
|
|
206
|
+
url=url,
|
|
207
|
+
data=body.getvalue(),
|
|
208
|
+
method="POST",
|
|
209
|
+
headers={
|
|
210
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
211
|
+
"Authorization": f"Bearer {api_key}",
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
return _openai_request(req)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _openai_request(req: urllib.request.Request) -> tuple[bytes, str]:
|
|
218
|
+
try:
|
|
219
|
+
with urllib.request.urlopen(req, timeout=180) as resp:
|
|
220
|
+
response = json.loads(resp.read().decode("utf-8"))
|
|
221
|
+
except urllib.error.HTTPError as e:
|
|
222
|
+
detail = e.read().decode("utf-8", errors="ignore")
|
|
223
|
+
raise RuntimeError(
|
|
224
|
+
f"OpenAI API error (HTTP {e.code}): {detail or e.reason}"
|
|
225
|
+
) from e
|
|
226
|
+
|
|
227
|
+
data_list = response.get("data", [])
|
|
228
|
+
if not data_list:
|
|
229
|
+
raise RuntimeError(f"No data in OpenAI response: {json.dumps(response, indent=2)}")
|
|
230
|
+
|
|
231
|
+
b64 = data_list[0].get("b64_json")
|
|
232
|
+
if not b64:
|
|
233
|
+
raise RuntimeError("No b64_json in OpenAI response.")
|
|
234
|
+
|
|
235
|
+
return base64.b64decode(b64), "image/png"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --- Gemini Generation ---
|
|
239
|
+
|
|
240
|
+
def generate_gemini(
|
|
241
|
+
api_key: str,
|
|
242
|
+
prompt: str,
|
|
243
|
+
model: str,
|
|
244
|
+
aspect_ratio: str,
|
|
245
|
+
reference_path: str | None = None,
|
|
246
|
+
) -> tuple[bytes, str]:
|
|
247
|
+
"""Generate image via Gemini API."""
|
|
248
|
+
url = (
|
|
249
|
+
"https://generativelanguage.googleapis.com/"
|
|
250
|
+
f"v1beta/models/{model}:generateContent?key={api_key}"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
parts: list[dict] = []
|
|
254
|
+
if reference_path and Path(reference_path).is_file():
|
|
255
|
+
parts.append({
|
|
256
|
+
"inlineData": {
|
|
257
|
+
"mimeType": safe_mime(reference_path),
|
|
258
|
+
"data": encode_file(reference_path),
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
parts.append({"text": prompt})
|
|
262
|
+
|
|
263
|
+
payload = {
|
|
264
|
+
"contents": [{"parts": parts}],
|
|
265
|
+
"generationConfig": {
|
|
266
|
+
"imageConfig": {"aspectRatio": aspect_ratio},
|
|
267
|
+
"responseModalities": ["TEXT", "IMAGE"],
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
req = urllib.request.Request(
|
|
272
|
+
url=url,
|
|
273
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
274
|
+
method="POST",
|
|
275
|
+
headers={"Content-Type": "application/json"},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
280
|
+
response = json.loads(resp.read().decode("utf-8"))
|
|
281
|
+
except urllib.error.HTTPError as e:
|
|
282
|
+
detail = e.read().decode("utf-8", errors="ignore")
|
|
283
|
+
raise RuntimeError(
|
|
284
|
+
f"Gemini API error (HTTP {e.code}): {detail or e.reason}"
|
|
285
|
+
) from e
|
|
286
|
+
|
|
287
|
+
candidates = response.get("candidates", [])
|
|
288
|
+
if not candidates:
|
|
289
|
+
raise RuntimeError("No candidates in Gemini response.")
|
|
290
|
+
|
|
291
|
+
parts = candidates[0].get("content", {}).get("parts", [])
|
|
292
|
+
for part in parts:
|
|
293
|
+
inline = part.get("inlineData")
|
|
294
|
+
if not inline:
|
|
295
|
+
continue
|
|
296
|
+
mime = inline.get("mimeType")
|
|
297
|
+
img_data = inline.get("data")
|
|
298
|
+
if mime and img_data:
|
|
299
|
+
return base64.b64decode(img_data), mime
|
|
300
|
+
|
|
301
|
+
raise RuntimeError(
|
|
302
|
+
f"No image in Gemini response: {json.dumps(response, indent=2)}"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --- CLI ---
|
|
307
|
+
|
|
308
|
+
def main() -> None:
|
|
309
|
+
parser = argparse.ArgumentParser(
|
|
310
|
+
description="Generate images using OpenAI (default) or Gemini.",
|
|
311
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
312
|
+
epilog="""
|
|
313
|
+
Examples:
|
|
314
|
+
%(prog)s --prompt "A cat on a skateboard"
|
|
315
|
+
%(prog)s --prompt "Oil painting of a forest" --quality high
|
|
316
|
+
%(prog)s --provider gemini --prompt "Watercolor sunset" --aspect-ratio 16:9
|
|
317
|
+
%(prog)s --prompt "Add wings to this bird" --reference bird.png
|
|
318
|
+
%(prog)s --provider gemini --reference face.png --prompt "Same person at beach" --aspect-ratio 9:16
|
|
319
|
+
""",
|
|
320
|
+
)
|
|
321
|
+
parser.add_argument(
|
|
322
|
+
"--provider", choices=["openai", "gemini"], default="openai",
|
|
323
|
+
help="Image generation provider. Default: openai.",
|
|
324
|
+
)
|
|
325
|
+
parser.add_argument(
|
|
326
|
+
"--prompt", required=True,
|
|
327
|
+
help="Text prompt describing the image to generate.",
|
|
328
|
+
)
|
|
329
|
+
parser.add_argument(
|
|
330
|
+
"--reference", default=None,
|
|
331
|
+
help="Path to a reference image. OpenAI uses edit mode; Gemini includes it as context.",
|
|
332
|
+
)
|
|
333
|
+
parser.add_argument(
|
|
334
|
+
"--model", default=None,
|
|
335
|
+
help=f"Model override. Defaults: OpenAI={OPENAI_DEFAULT_MODEL}, Gemini={GEMINI_DEFAULT_MODEL}.",
|
|
336
|
+
)
|
|
337
|
+
parser.add_argument(
|
|
338
|
+
"--aspect-ratio", default=DEFAULT_ASPECT_RATIO, choices=ALLOWED_ASPECT_RATIOS,
|
|
339
|
+
help=f"Aspect ratio. Default: {DEFAULT_ASPECT_RATIO}.",
|
|
340
|
+
)
|
|
341
|
+
parser.add_argument(
|
|
342
|
+
"--quality", default="auto", choices=OPENAI_QUALITIES,
|
|
343
|
+
help="OpenAI quality (gpt-image-1 only). Default: auto.",
|
|
344
|
+
)
|
|
345
|
+
parser.add_argument(
|
|
346
|
+
"--n", type=int, default=1,
|
|
347
|
+
help="Number of images (OpenAI only). Default: 1.",
|
|
348
|
+
)
|
|
349
|
+
parser.add_argument(
|
|
350
|
+
"--output", default=None,
|
|
351
|
+
help="Output path. Directory = timestamped file. Default: /tmp/.",
|
|
352
|
+
)
|
|
353
|
+
parser.add_argument(
|
|
354
|
+
"--api-key", default=None,
|
|
355
|
+
help="API key override. Otherwise reads from env var or ~/.niahere/config.yaml.",
|
|
356
|
+
)
|
|
357
|
+
args = parser.parse_args()
|
|
358
|
+
|
|
359
|
+
provider = args.provider
|
|
360
|
+
model = args.model or (OPENAI_DEFAULT_MODEL if provider == "openai" else GEMINI_DEFAULT_MODEL)
|
|
361
|
+
|
|
362
|
+
api_key = resolve_api_key(provider, args.api_key)
|
|
363
|
+
if not api_key:
|
|
364
|
+
config_key = "openai_api_key" if provider == "openai" else "gemini_api_key"
|
|
365
|
+
env_var = "OPENAI_API_KEY" if provider == "openai" else "GEMINI_API_KEY"
|
|
366
|
+
raise SystemExit(
|
|
367
|
+
f"Missing API key. Provide --api-key, set {env_var} in environment, "
|
|
368
|
+
f"or add {config_key} to {NIA_CONFIG}."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if args.reference and not Path(args.reference).expanduser().is_file():
|
|
372
|
+
raise SystemExit(f"Reference image not found: {args.reference}")
|
|
373
|
+
|
|
374
|
+
ref = str(Path(args.reference).expanduser()) if args.reference else None
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
if provider == "openai":
|
|
378
|
+
size = OPENAI_SIZE_MAP.get(args.aspect_ratio, "1024x1024")
|
|
379
|
+
image_data, mime = generate_openai(
|
|
380
|
+
api_key=api_key, prompt=args.prompt, model=model,
|
|
381
|
+
size=size, quality=args.quality, reference_path=ref, n=args.n,
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
image_data, mime = generate_gemini(
|
|
385
|
+
api_key=api_key, prompt=args.prompt, model=model,
|
|
386
|
+
aspect_ratio=args.aspect_ratio, reference_path=ref,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
ext = ".png" if "png" in mime else ".jpg"
|
|
390
|
+
out = resolve_output_path(args.output, ext)
|
|
391
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
392
|
+
out.write_bytes(image_data)
|
|
393
|
+
print(f"Saved: {out}")
|
|
394
|
+
print(f"Provider: {provider} | Model: {model} | Size/Ratio: {args.aspect_ratio}")
|
|
395
|
+
except Exception as exc:
|
|
396
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
397
|
+
raise SystemExit(1) from exc
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
main()
|