Compare commits
2
Commits
31d9bb1270
...
1aa9836a26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aa9836a26 | ||
|
|
eea374d328 |
No files matched your search
+13
-2
@@ -73,9 +73,20 @@ PUBLIC_BASE_URL=http://localhost:4321
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 4. Module: ai — image generation
|
# 4. Module: ai — image generation
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Klarbild calls image models through OpenRouter. This costs money per image.
|
# Klarbild calls image models over an OpenAI-shaped images endpoint. This costs
|
||||||
# The key can also be entered in the admin area; the database value wins.
|
# 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=
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -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 |
|
| Module | What it adds | What it needs |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `print` | everything above | nothing — always on |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
routes answer 404 rather than 403 — a disabled instance does not advertise what it
|
||||||
could have done.
|
could have done.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
|
## Why self-hosted matters here
|
||||||
|
|
||||||
The pictures are family photos, passport photos, ID pictures and client work.
|
The pictures are family photos, passport photos, ID pictures and client work.
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ services:
|
|||||||
ADMIN_USERNAME: "${ADMIN_USERNAME:-admin}"
|
ADMIN_USERNAME: "${ADMIN_USERNAME:-admin}"
|
||||||
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
|
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:-}"
|
OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}"
|
||||||
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}"
|
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}"
|
||||||
DELIVERY_DEFAULT_FOLDER: "${DELIVERY_DEFAULT_FOLDER:-klarbild}"
|
DELIVERY_DEFAULT_FOLDER: "${DELIVERY_DEFAULT_FOLDER:-klarbild}"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
+35
-6
@@ -1,16 +1,36 @@
|
|||||||
import { one } from './db';
|
import { one } from './db';
|
||||||
import { decrypt } from './crypto';
|
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<string> {
|
export async function resolveKey(): Promise<string> {
|
||||||
const row = await one<{ openrouter_key_enc: string | null }>(
|
const row = await one<{ openrouter_key_enc: string | null }>(
|
||||||
'SELECT openrouter_key_enc FROM settings WHERE id=1');
|
'SELECT openrouter_key_enc FROM settings WHERE id=1');
|
||||||
if (row?.openrouter_key_enc) {
|
if (row?.openrouter_key_enc) {
|
||||||
try { return decrypt(row.openrouter_key_enc); } catch { /* falls back to ENV */ }
|
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 {
|
export class OpenRouterError extends Error {
|
||||||
@@ -49,7 +69,7 @@ export interface GenerateResult {
|
|||||||
/** The dedicated image endpoint POST /api/v1/images (not chat/completions). */
|
/** The dedicated image endpoint POST /api/v1/images (not chat/completions). */
|
||||||
export async function generateImage(opts: GenerateOpts): Promise<GenerateResult> {
|
export async function generateImage(opts: GenerateOpts): Promise<GenerateResult> {
|
||||||
const key = await resolveKey();
|
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<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
@@ -69,6 +89,7 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`,
|
Authorization: `Bearer ${key}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
// OpenRouter shows this on the activity page. Other endpoints ignore it.
|
||||||
'X-Title': 'Klarbild',
|
'X-Title': 'Klarbild',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -76,7 +97,7 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new OpenRouterError(res.status, friendlyFor(res.status),
|
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();
|
const json: any = await res.json();
|
||||||
@@ -90,12 +111,20 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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<any[]> {
|
export async function listImageModels(): Promise<any[]> {
|
||||||
const key = await resolveKey();
|
const key = await resolveKey();
|
||||||
const res = await fetch(`${BASE}/images/models`, {
|
const res = await fetch(`${BASE}/images/models`, {
|
||||||
headers: { Authorization: `Bearer ${key}` },
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
});
|
});
|
||||||
|
if (res.status === 404 && !usingOpenRouter) return [];
|
||||||
if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status));
|
if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status));
|
||||||
return (await res.json())?.data ?? [];
|
return (await res.json())?.data ?? [];
|
||||||
}
|
}
|
||||||
+8
-4
@@ -81,15 +81,19 @@ async function seedRecipes(): Promise<void> {
|
|||||||
const by = admin?.id ?? null;
|
const by = admin?.id ?? null;
|
||||||
const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record<string, unknown> = {}) =>
|
const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record<string, unknown> = {}) =>
|
||||||
query(
|
query(
|
||||||
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm)
|
`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)`,
|
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,
|
[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('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true });
|
||||||
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
|
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('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 */
|
/* --------------------------------------------------------- print presets */
|
||||||
|
|||||||
Reference in new issue
Block a user