feat(picture,api): GPT-Image-2 image generation

Adds a third provider path to /api/v1/picture/generate that calls OpenAI
gpt-image-2 when model starts with "openai/". Supports n=1..4 batch
generation with character continuity, base64 response decoded server-side
and uploaded to mana-media for dedup + thumbnails. Credit cost scales
by quality (low=3, medium=10, high=25) × n.

Env plumbing:
- scripts/generate-env.mjs: new apps/api/.env stanza propagates
  OPENAI_API_KEY + REPLICATE_API_TOKEN from .env.secrets
- .env.macmini.example: documents OPENAI_API_KEY for prod

Frontend /picture/generate: model + quality + aspect-ratio + batch-count
selectors, real fetch with auth, persists each image via imagesStore.insert
(encrypted + synced). Wrapped in ModuleShell variant=fill with back-arrow
to /picture and a live credit badge in the header actions slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 00:37:15 +02:00
parent 13b785b33f
commit 3a68a63728
4 changed files with 459 additions and 131 deletions

View file

@ -76,6 +76,13 @@ SUPABASE_SERVICE_ROLE_KEY=
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_API_KEY=your-api-key-here
# ============================================
# OpenAI (direct, non-Azure)
# ============================================
# Consumed by mana-research (deep research) and mana-api picture module
# for gpt-image-2 image generation. Distinct from AZURE_OPENAI_* above.
OPENAI_API_KEY=
# ============================================
# Monitoring (Grafana)
# ============================================

View file

@ -12,27 +12,90 @@ import type { AuthVariables } from '@mana/shared-hono';
const REPLICATE_TOKEN = process.env.REPLICATE_API_TOKEN || '';
const IMAGE_GEN_URL = process.env.MANA_IMAGE_GEN_URL || '';
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || '';
// Credit cost for OpenAI gpt-image-2 by quality. Reflects ~$0.006 / $0.053 / $0.211
// per 1024² image so users bear roughly linear cost (1 credit ≈ $0.008).
// Flux/local stays at the flat 10-credit legacy rate.
function creditsFor(model: string | undefined, quality: string | undefined): number {
if (model?.startsWith('openai/')) {
if (quality === 'low') return 3;
if (quality === 'high') return 25;
return 10; // medium / auto
}
return 10;
}
type OpenAiSize = '1024x1024' | '1536x1024' | '1024x1536' | 'auto';
function resolveOpenAiSize(width?: number, height?: number): OpenAiSize {
if (!width || !height) return '1024x1024';
const landscape = width > height;
const portrait = height > width;
if (landscape) return '1536x1024';
if (portrait) return '1024x1536';
return '1024x1024';
}
const routes = new Hono<{ Variables: AuthVariables }>();
// ─── AI Image Generation (server-only: Replicate/local) ─────
// ─── AI Image Generation (server-only: Replicate/local/OpenAI) ─────
routes.post('/generate', async (c) => {
const userId = c.get('userId');
const { prompt, model, width, height, negativePrompt, steps, guidanceScale } = await c.req.json();
const { prompt, model, width, height, negativePrompt, steps, guidanceScale, quality, n } =
await c.req.json();
if (!prompt) return c.json({ error: 'prompt required' }, 400);
const cost = 10;
// Batch count. OpenAI gpt-image-2 supports up to 8; we clamp to 4 to stay
// well under Tier-1 IPM limits and cap credit exposure on accidental max-n.
// Non-OpenAI paths ignore this (Replicate/local produce a single image).
const batchCount = Math.max(1, Math.min(4, Number(n) || 1));
const effectiveBatch = model?.startsWith('openai/') ? batchCount : 1;
const cost = creditsFor(model, quality) * effectiveBatch;
const validation = await validateCredits(userId, 'AI_IMAGE_GENERATION', cost);
if (!validation.hasCredits) {
return c.json({ error: 'Insufficient credits', required: cost }, 402);
}
try {
let imageUrl: string;
const imageUrls: string[] = [];
const imageBuffers: ArrayBuffer[] = [];
if (model?.startsWith('local/') && IMAGE_GEN_URL) {
if (model?.startsWith('openai/') && OPENAI_API_KEY) {
// OpenAI gpt-image-2 — returns base64, not URL, supports n > 1
const openaiModel = model.slice('openai/'.length) || 'gpt-image-2';
const res = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: openaiModel,
prompt,
size: resolveOpenAiSize(width, height),
quality: quality || 'medium',
n: effectiveBatch,
}),
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
return c.json({ error: 'OpenAI image API failed', detail: detail.slice(0, 500) }, 502);
}
const data = (await res.json()) as { data?: Array<{ b64_json?: string }> };
const blobs = (data.data ?? []).map((d) => d.b64_json).filter((b): b is string => !!b);
if (blobs.length === 0) return c.json({ error: 'OpenAI returned no image data' }, 502);
for (const b64 of blobs) {
const binary = Buffer.from(b64, 'base64');
imageBuffers.push(
binary.buffer.slice(
binary.byteOffset,
binary.byteOffset + binary.byteLength
) as ArrayBuffer
);
}
} else if (model?.startsWith('local/') && IMAGE_GEN_URL) {
// Local generation via mana-image-gen
const res = await fetch(`${IMAGE_GEN_URL}/generate`, {
method: 'POST',
@ -48,7 +111,8 @@ routes.post('/generate', async (c) => {
});
if (!res.ok) return c.json({ error: 'Local generation failed' }, 502);
const data = await res.json();
imageUrl = data.image_url || data.url;
const localUrl = data.image_url || data.url;
if (localUrl) imageUrls.push(localUrl);
} else if (REPLICATE_TOKEN) {
// Cloud generation via Replicate
const res = await fetch('https://api.replicate.com/v1/predictions', {
@ -92,33 +156,70 @@ routes.post('/generate', async (c) => {
}
}
imageUrl = Array.isArray(output) ? output[0] : output;
const replicateUrl = Array.isArray(output) ? output[0] : output;
if (replicateUrl) imageUrls.push(replicateUrl);
} else {
return c.json({ error: 'No image generation service configured' }, 503);
}
const producedCount = imageBuffers.length + imageUrls.length;
if (producedCount === 0) return c.json({ error: 'Generation produced no image' }, 502);
await consumeCredits(userId, 'AI_IMAGE_GENERATION', cost, `Image: ${prompt.slice(0, 50)}`);
// Store generated image in mana-media for dedup, thumbnails & Photos gallery
// Store each generated image in mana-media for dedup, thumbnails & Photos gallery.
// OpenAI contributed pre-decoded buffers; Replicate/local contributed URLs to fetch.
try {
const { uploadImageToMedia } = await import('../../lib/media');
const imgRes = await fetch(imageUrl);
const imgBuffer = await imgRes.arrayBuffer();
const media = await uploadImageToMedia(imgBuffer, `generated-${Date.now()}.png`, {
app: 'picture',
userId,
});
const images: Array<{ imageUrl: string; mediaId: string; thumbnailUrl?: string }> = [];
const ts = Date.now();
let idx = 0;
for (const buf of imageBuffers) {
const media = await uploadImageToMedia(buf, `generated-${ts}-${idx}.png`, {
app: 'picture',
userId,
});
images.push({
imageUrl: media.urls.original,
mediaId: media.id,
thumbnailUrl: media.urls.thumbnail,
});
idx++;
}
for (const url of imageUrls) {
const imgRes = await fetch(url);
const imgBuffer = await imgRes.arrayBuffer();
const media = await uploadImageToMedia(imgBuffer, `generated-${ts}-${idx}.png`, {
app: 'picture',
userId,
});
images.push({
imageUrl: media.urls.original,
mediaId: media.id,
thumbnailUrl: media.urls.thumbnail,
});
idx++;
}
return c.json({
imageUrl: media.urls.original,
mediaId: media.id,
thumbnailUrl: media.urls.thumbnail,
images,
prompt,
model: model || 'flux-schnell',
// Back-compat: first image exposed at top level too.
imageUrl: images[0]?.imageUrl,
mediaId: images[0]?.mediaId,
thumbnailUrl: images[0]?.thumbnailUrl,
});
} catch {
// Fallback: return raw imageUrls if mana-media is unavailable. OpenAI's
// base64-only path has no fallback URL — surface an error instead.
if (imageUrls.length === 0) return c.json({ error: 'Media upload failed' }, 502);
return c.json({
images: imageUrls.map((u) => ({ imageUrl: u })),
imageUrl: imageUrls[0],
prompt,
model: model || 'flux-schnell',
});
} catch {
// Fallback: return raw imageUrl if mana-media is unavailable
return c.json({ imageUrl, prompt, model: model || 'flux-schnell' });
}
} catch (_err) {
return c.json({ error: 'Generation failed' }, 500);

View file

@ -1,22 +1,133 @@
<script lang="ts">
import { CheckCircle, Sparkle, Lightning } from '@mana/shared-icons';
import { goto } from '$app/navigation';
import { Sparkle, Lightning, Image as ImageIcon } from '@mana/shared-icons';
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
import type { LocalImage } from '$lib/modules/picture/types';
import { ModuleShell } from '$lib/components/shell';
type ProviderOption = {
id: string;
label: string;
description: string;
supportsQuality: boolean;
};
const PROVIDERS: ProviderOption[] = [
{
id: 'openai/gpt-image-2',
label: 'GPT-Image-2',
description: 'OpenAI · Text im Bild, Reasoning, hohe Detailtreue',
supportsQuality: true,
},
{
id: 'black-forest-labs/flux-schnell',
label: 'Flux Schnell',
description: 'Replicate · schnell und günstig',
supportsQuality: false,
},
];
type AspectRatio = { id: string; label: string; width: number; height: number };
const ASPECT_RATIOS: AspectRatio[] = [
{ id: 'square', label: 'Quadrat (1:1)', width: 1024, height: 1024 },
{ id: 'landscape', label: 'Landschaft (3:2)', width: 1536, height: 1024 },
{ id: 'portrait', label: 'Portrait (2:3)', width: 1024, height: 1536 },
];
let prompt = $state('');
let negativePrompt = $state('');
let modelId = $state<string>(PROVIDERS[0].id);
let quality = $state<'low' | 'medium' | 'high'>('medium');
let aspectId = $state<string>(ASPECT_RATIOS[0].id);
let batchCount = $state<1 | 2 | 4>(1);
let isGenerating = $state(false);
let generationError = $state('');
let lastImageUrls = $state<string[]>([]);
const currentProvider = $derived(PROVIDERS.find((p) => p.id === modelId) ?? PROVIDERS[0]);
const currentAspect = $derived(ASPECT_RATIOS.find((a) => a.id === aspectId) ?? ASPECT_RATIOS[0]);
const supportsBatch = $derived(currentProvider.id.startsWith('openai/'));
const effectiveBatch = $derived(supportsBatch ? batchCount : 1);
const creditsPerImage = $derived(quality === 'low' ? 3 : quality === 'high' ? 25 : 10);
const totalCredits = $derived(
currentProvider.supportsQuality ? creditsPerImage * effectiveBatch : 10
);
async function handleGenerate() {
if (!prompt.trim()) return;
isGenerating = true;
generationError = '';
lastImageUrls = [];
try {
// TODO: Connect to Picture backend API for image generation
// For now, show a placeholder message
await new Promise((resolve) => setTimeout(resolve, 2000));
generationError = 'Bildgenerierung erfordert eine Verbindung zum Picture-Server (Port 3006).';
const token = await authStore.getValidToken();
if (!token) throw new Error('Nicht angemeldet');
const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
prompt: prompt.trim(),
negativePrompt: negativePrompt.trim() || undefined,
model: modelId,
quality: currentProvider.supportsQuality ? quality : undefined,
width: currentAspect.width,
height: currentAspect.height,
n: effectiveBatch,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
if (res.status === 402)
throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`);
throw new Error(body.error || `Fehler ${res.status}`);
}
const data = (await res.json()) as {
images?: Array<{ imageUrl: string; mediaId?: string; thumbnailUrl?: string }>;
imageUrl?: string;
mediaId?: string;
thumbnailUrl?: string;
prompt: string;
model: string;
};
const images =
data.images && data.images.length > 0
? data.images
: data.imageUrl
? [{ imageUrl: data.imageUrl, mediaId: data.mediaId, thumbnailUrl: data.thumbnailUrl }]
: [];
if (images.length === 0) throw new Error('Keine Bilder zurückgegeben');
const now = new Date().toISOString();
for (const img of images) {
const local: LocalImage = {
id: crypto.randomUUID(),
prompt: data.prompt,
negativePrompt: negativePrompt.trim() || null,
model: data.model,
publicUrl: img.imageUrl,
storagePath: img.mediaId ?? img.imageUrl,
filename: `generated-${Date.now()}.png`,
format: 'png',
width: currentAspect.width,
height: currentAspect.height,
isPublic: false,
isFavorite: false,
downloadCount: 0,
createdAt: now,
updatedAt: now,
};
await imagesStore.insert(local);
}
lastImageUrls = images.map((i) => i.imageUrl);
} catch (e) {
generationError = e instanceof Error ? e.message : 'Generierung fehlgeschlagen';
} finally {
@ -37,117 +148,201 @@
<title>Generieren - Picture - Mana</title>
</svelte:head>
<div class="mx-auto max-w-3xl p-4">
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Bild generieren</h1>
<p class="mt-1 text-sm text-muted-foreground">
Erstelle beeindruckende KI-Bilder aus deinen Textbeschreibungen
</p>
</header>
<!-- Generate Form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleGenerate();
}}
class="space-y-4"
>
<!-- Prompt -->
<div>
<label for="prompt" class="mb-1 block text-sm font-medium text-foreground"> Prompt </label>
<textarea
id="prompt"
bind:value={prompt}
placeholder="Beschreibe das Bild, das du erstellen möchtest..."
rows="4"
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary resize-none"
></textarea>
</div>
<!-- Negative Prompt -->
<div>
<label for="negative-prompt" class="mb-1 block text-sm font-medium text-foreground">
Negativ-Prompt (optional)
</label>
<input
id="negative-prompt"
type="text"
bind:value={negativePrompt}
placeholder="Was soll nicht im Bild sein... (z.B. unscharf, verzerrt)"
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<!-- Generate Button -->
<button
type="submit"
disabled={!prompt.trim() || isGenerating}
class="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
<ModuleShell
variant="fill"
title="Bild generieren"
icon={ImageIcon}
color="#8B5CF6"
backHref="/picture"
>
{#snippet actions()}
<span
class="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground"
title="Kosten für diesen Run"
>
{#if isGenerating}
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
></div>
Generiere...
{:else}
<Sparkle size={18} />
Bild generieren
{/if}
</button>
<Lightning size={12} weight="fill" class="text-primary" />
{totalCredits}
</span>
{/snippet}
{#if generationError}
<div
class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/20 dark:text-amber-200"
>
{generationError}
<div class="mx-auto max-w-2xl space-y-5 p-4 sm:p-6">
<!-- Prompt section -->
<section class="space-y-3">
<div>
<label for="prompt" class="mb-1.5 block text-sm font-medium text-foreground">
Prompt
</label>
<textarea
id="prompt"
bind:value={prompt}
placeholder="Beschreibe das Bild, das du erstellen möchtest..."
rows="4"
required
class="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
></textarea>
</div>
{/if}
</form>
<!-- Prompt Suggestions -->
<div class="mt-8">
<h2 class="mb-3 text-sm font-medium text-muted-foreground uppercase tracking-wide">
Prompt-Vorschläge
</h2>
<div class="flex flex-wrap gap-2">
{#each PROMPT_SUGGESTIONS as suggestion}
<button
onclick={() => (prompt = suggestion)}
class="rounded-full border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/50 hover:text-foreground transition-colors"
<div class="flex flex-wrap gap-1.5">
{#each PROMPT_SUGGESTIONS as suggestion}
<button
type="button"
onclick={() => (prompt = suggestion)}
class="rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
{suggestion}
</button>
{/each}
</div>
<div>
<label for="negative-prompt" class="mb-1.5 block text-sm font-medium text-foreground">
Negativ-Prompt <span class="text-muted-foreground">(optional)</span>
</label>
<input
id="negative-prompt"
type="text"
bind:value={negativePrompt}
placeholder="unscharf, verzerrt, niedrige Qualität…"
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
</section>
<!-- Settings section -->
<section class="space-y-3 rounded-lg border border-border bg-background/50 p-3">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Einstellungen
</h2>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="model" class="mb-1.5 block text-sm font-medium text-foreground">
Modell
</label>
<select
id="model"
bind:value={modelId}
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
>
{#each PROVIDERS as p}
<option value={p.id}>{p.label}</option>
{/each}
</select>
<p class="mt-1 text-xs text-muted-foreground">{currentProvider.description}</p>
</div>
<div>
<label for="aspect" class="mb-1.5 block text-sm font-medium text-foreground">
Seitenverhältnis
</label>
<select
id="aspect"
bind:value={aspectId}
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
>
{#each ASPECT_RATIOS as a}
<option value={a.id}>{a.label}</option>
{/each}
</select>
</div>
</div>
{#if currentProvider.supportsQuality}
<div>
<span class="mb-1.5 block text-sm font-medium text-foreground">Qualität</span>
<div class="inline-flex w-full overflow-hidden rounded-md border border-border">
{#each ['low', 'medium', 'high'] as const as q, i}
<button
type="button"
onclick={() => (quality = q)}
class="flex-1 px-3 py-1.5 text-sm transition-colors {i > 0
? 'border-l border-border'
: ''} {quality === q
? 'bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:text-foreground'}"
>
{q === 'low' ? 'Niedrig' : q === 'medium' ? 'Mittel' : 'Hoch'}
<span class="ml-1 text-xs opacity-70">
{q === 'low' ? '3' : q === 'medium' ? '10' : '25'}
</span>
</button>
{/each}
</div>
</div>
{/if}
{#if supportsBatch}
<div>
<span class="mb-1.5 block text-sm font-medium text-foreground">Varianten</span>
<div class="inline-flex w-full overflow-hidden rounded-md border border-border">
{#each [1, 2, 4] as const as c, i}
<button
type="button"
onclick={() => (batchCount = c)}
class="flex-1 px-3 py-1.5 text-sm transition-colors {i > 0
? 'border-l border-border'
: ''} {batchCount === c
? 'bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:text-foreground'}"
>
{c}×
</button>
{/each}
</div>
</div>
{/if}
</section>
<!-- Generate button + error -->
<div class="space-y-3">
<button
type="button"
onclick={handleGenerate}
disabled={!prompt.trim() || isGenerating}
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isGenerating}
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
></div>
Generiere…
{:else}
<Sparkle size={16} weight="fill" />
Bild generieren · {totalCredits} Credits
{/if}
</button>
{#if generationError}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
role="alert"
>
{suggestion}
</button>
{/each}
{generationError}
</div>
{/if}
</div>
</div>
<!-- Tips -->
<div class="mt-8 rounded-lg border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-foreground">Tipps für bessere Ergebnisse</h3>
<ul class="space-y-2 text-sm text-muted-foreground">
<li class="flex items-start gap-2">
<CheckCircle size={16} class="mt-0.5 flex-shrink-0 text-primary" />
<span
><strong class="text-foreground">Sei spezifisch:</strong> Beschreibe Stil, Stimmung, Farben
und Komposition</span
>
</li>
<li class="flex items-start gap-2">
<CheckCircle size={16} class="mt-0.5 flex-shrink-0 text-primary" />
<span
><strong class="text-foreground">Beschreibende Wörter:</strong> "Lebhafter Sonnenuntergang
über Bergen" ist besser als "Sonnenuntergang"</span
>
</li>
<li class="flex items-start gap-2">
<CheckCircle size={16} class="mt-0.5 flex-shrink-0 text-primary" />
<span
><strong class="text-foreground">Negativ-Prompts:</strong> Schließe unerwünschte Elemente aus
(z.B. "unscharf, verzerrt, niedrige Qualität")</span
>
</li>
</ul>
<!-- Results -->
{#if lastImageUrls.length > 0}
<section class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">
{lastImageUrls.length === 1 ? 'Ergebnis' : `${lastImageUrls.length} Ergebnisse`}
</h2>
<button
type="button"
onclick={() => goto('/picture')}
class="text-xs font-medium text-primary hover:underline"
>
Zur Galerie →
</button>
</div>
<div class="grid gap-3 {lastImageUrls.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}">
{#each lastImageUrls as url}
<img src={url} alt={prompt} class="w-full rounded-md border border-border bg-card" />
{/each}
</div>
</section>
{/if}
</div>
</div>
</ModuleShell>

View file

@ -75,6 +75,31 @@ const APP_CONFIGS = [
},
},
// Unified Mana API (Hono + Bun, Port 3060) — consolidates per-module servers
{
path: 'apps/api/.env',
vars: {
NODE_ENV: () => 'development',
PORT: (env) => env.MANA_API_PORT || '3060',
MANA_AUTH_URL: (env) => env.MANA_AUTH_URL || 'http://localhost:3001',
MANA_LLM_URL: (env) => env.MANA_LLM_URL || 'http://localhost:3025',
MANA_SEARCH_URL: (env) => env.MANA_SEARCH_URL || 'http://localhost:3021',
MANA_CREDITS_URL: (env) => env.MANA_CREDITS_URL || 'http://localhost:3061',
MANA_MEDIA_URL: (env) => env.MANA_MEDIA_URL || 'http://localhost:3015',
MANA_CRAWLER_URL: (env) => env.MANA_CRAWLER_URL || 'http://localhost:3014',
MANA_IMAGE_GEN_URL: (env) => env.MANA_IMAGE_GEN_URL || '',
MANA_SERVICE_KEY: (env) => env.MANA_SERVICE_KEY || 'dev-service-key',
DATABASE_URL: (env) =>
env.MANA_API_DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform',
CORS_ORIGINS: (env) => env.CORS_ORIGINS || 'http://localhost:5173',
APP_ID: () => 'mana-api',
LOGGER_FORMAT: (env) => env.LOGGER_FORMAT || 'pretty',
// Picture module providers
OPENAI_API_KEY: (env) => env.OPENAI_API_KEY || '',
REPLICATE_API_TOKEN: (env) => env.REPLICATE_API_TOKEN || '',
},
},
// Mana Research Service (Hono + Bun, Port 3068)
{
path: 'services/mana-research/.env',