diff --git a/.env.example b/.env.example index 2be6f9d..5f5fe25 100644 --- a/.env.example +++ b/.env.example @@ -73,9 +73,20 @@ PUBLIC_BASE_URL=http://localhost:4321 # ───────────────────────────────────────────────────────────────────────────── # 4. Module: ai — image generation # ───────────────────────────────────────────────────────────────────────────── -# Klarbild calls image models through OpenRouter. This costs money per image. -# The key can also be entered in the admin area; the database value wins. +# Klarbild calls image models over an OpenAI-shaped images endpoint. This costs +# money per image, whoever you call. The key can also be entered in the admin +# area, and that value wins over anything set here. +IMAGE_API_KEY= + +# Where those requests go. The default is OpenRouter, which is one key for many +# models. Point this at anything that answers POST {base}/images in the OpenAI +# image shape — a LiteLLM gateway, a company proxy, a server of your own — and +# Klarbild talks to that instead. +IMAGE_API_BASE_URL=https://openrouter.ai/api/v1 + +# The old name for IMAGE_API_KEY. Still read, so instances set up before the +# endpoint became configurable keep working. OPENROUTER_API_KEY= # ───────────────────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index 968832d..7759294 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ of the PDF above measures 210.00 × 297.00 mm, because that is what A4 is. | Module | What it adds | What it needs | |---|---|---| | `print` | everything above | nothing — always on | -| `ai` | clean up screenshots, convert, combine, generate | an OpenRouter key (paid per image) | +| `ai` | clean up screenshots, convert, combine, generate | a key for an images API (paid per image) | | `delivery` | push finished files to an SFTP/FTPS server you already have | that server | | `mirror` | a second copy of every result, sorted by month | a second target | | `share` | public links: expiring, revocable, optional passphrase, ZIP download | a public URL | @@ -92,6 +92,17 @@ A module that is off is off: no navigation entry, no settings card, and its API routes answer 404 rather than 403 — a disabled instance does not advertise what it could have done. +![The studio with the AI module on: four tasks, saved presets, exact output size](docs/screenshots/studio.png) + +### Which image model + +The `ai` module posts to `{IMAGE_API_BASE_URL}/images` in the OpenAI image shape. +The default base is OpenRouter — one key, many models, and the model list in the +admin area fills itself in. Point `IMAGE_API_BASE_URL` at a LiteLLM gateway, a +company proxy or a server of your own and Klarbild talks to that instead; you then +type the model id rather than picking it from a list. Either way the pictures go to +exactly one endpoint and you chose which. + ## Why self-hosted matters here The pictures are family photos, passport photos, ID pictures and client work. diff --git a/docker-compose.full.yaml b/docker-compose.full.yaml index 2807068..f5c36ea 100644 --- a/docker-compose.full.yaml +++ b/docker-compose.full.yaml @@ -35,6 +35,8 @@ services: ADMIN_USERNAME: "${ADMIN_USERNAME:-admin}" ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}" + IMAGE_API_KEY: "${IMAGE_API_KEY:-}" + IMAGE_API_BASE_URL: "${IMAGE_API_BASE_URL:-https://openrouter.ai/api/v1}" OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}" TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}" DELIVERY_DEFAULT_FOLDER: "${DELIVERY_DEFAULT_FOLDER:-klarbild}" diff --git a/docs/screenshots/studio.png b/docs/screenshots/studio.png new file mode 100644 index 0000000..ae843ab Binary files /dev/null and b/docs/screenshots/studio.png differ diff --git a/src/lib/openrouter.ts b/src/lib/openrouter.ts index ba0085f..566a84a 100644 --- a/src/lib/openrouter.ts +++ b/src/lib/openrouter.ts @@ -1,16 +1,36 @@ import { one } from './db'; import { decrypt } from './crypto'; -const BASE = 'https://openrouter.ai/api/v1'; +/** + * Where the image requests go. + * + * OpenRouter by default, because it is one key for many models. Point + * `IMAGE_API_BASE_URL` somewhere else and Klarbild talks to that instead — any + * endpoint that answers `POST {base}/images` in the OpenAI image shape will do, + * which covers a gateway like LiteLLM, a company proxy, or a local server. The + * request body is written to that shape deliberately; the only OpenRouter + * extras are the `X-Title` header and the `usage.cost` field, and both are + * optional on the way back. + * + * Whatever you point it at, the images go to exactly one place and you chose it. + */ +const BASE = (process.env.IMAGE_API_BASE_URL || 'https://openrouter.ai/api/v1').replace(/\/+$/, ''); -/** Key: the DB value (set in the admin area) takes precedence over the environment variable. */ +/** True when this instance talks to OpenRouter rather than to something else. */ +export const usingOpenRouter = BASE.includes('openrouter.ai'); + +/** + * Key: the value from the admin area wins, then `IMAGE_API_KEY`, then + * `OPENROUTER_API_KEY` — the last one so that instances set up before the base + * URL became configurable keep working untouched. + */ export async function resolveKey(): Promise { const row = await one<{ openrouter_key_enc: string | null }>( 'SELECT openrouter_key_enc FROM settings WHERE id=1'); if (row?.openrouter_key_enc) { try { return decrypt(row.openrouter_key_enc); } catch { /* falls back to ENV */ } } - return process.env.OPENROUTER_API_KEY || ''; + return process.env.IMAGE_API_KEY || process.env.OPENROUTER_API_KEY || ''; } export class OpenRouterError extends Error { @@ -49,7 +69,7 @@ export interface GenerateResult { /** The dedicated image endpoint POST /api/v1/images (not chat/completions). */ export async function generateImage(opts: GenerateOpts): Promise { const key = await resolveKey(); - if (!key) throw new OpenRouterError(401, friendlyFor(401), 'No OpenRouter key stored'); + if (!key) throw new OpenRouterError(401, friendlyFor(401), 'No API key stored for the image service'); const body: Record = { model: opts.model, @@ -69,6 +89,7 @@ export async function generateImage(opts: GenerateOpts): Promise headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', + // OpenRouter shows this on the activity page. Other endpoints ignore it. 'X-Title': 'Klarbild', }, body: JSON.stringify(body), @@ -76,7 +97,7 @@ export async function generateImage(opts: GenerateOpts): Promise if (!res.ok) { throw new OpenRouterError(res.status, friendlyFor(res.status), - `OpenRouter ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300)); + `Image API ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300)); } const json: any = await res.json(); @@ -90,12 +111,20 @@ export async function generateImage(opts: GenerateOpts): Promise }; } -/** The available image models (for the model list in the admin area). */ +/** + * The available image models, for the picker in the admin area. + * + * `GET {base}/images/models` is an OpenRouter route. An endpoint that does not + * have it answers 404, and that is not an error worth showing: it only means + * the list cannot be filled in automatically, and the model id has to be typed. + * Anything else — a wrong key, a dead host — is still reported. + */ export async function listImageModels(): Promise { const key = await resolveKey(); const res = await fetch(`${BASE}/images/models`, { headers: { Authorization: `Bearer ${key}` }, }); + if (res.status === 404 && !usingOpenRouter) return []; if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status)); return (await res.json())?.data ?? []; } diff --git a/src/lib/seed.ts b/src/lib/seed.ts index a8496a2..ea32bc1 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -81,15 +81,19 @@ async function seedRecipes(): Promise { const by = admin?.id ?? null; const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record = {}) => query( - `INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + `INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm, output_ext) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [name, JSON.stringify(tasks), fmt, orient, (extra.crop_mode as string) || 'crop', 300, - (extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null]); + (extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null, + (extra.output_ext as string) ?? null]); + // Examples, meant to be read and then replaced. Each one shows a different + // combination of the four tasks rather than a different size. await R('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true }); await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait'); - await R('TV frame (16:9)', ['clean', 'format'], 'tv169', 'landscape'); + await R('Cut out, transparent PNG', ['clean', 'cutout'], 'keep', 'landscape', { output_ext: 'png' }); await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', { contour_mm: 3 }); + await R('Square 30 × 30 for social', ['clean', 'format'], '30x30', 'landscape'); } /* --------------------------------------------------------- print presets */