mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
13b785b33f
commit
3a68a63728
4 changed files with 459 additions and 131 deletions
|
|
@ -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)
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
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,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
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({
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
<ModuleShell
|
||||
variant="fill"
|
||||
title="Bild generieren"
|
||||
icon={ImageIcon}
|
||||
color="#8B5CF6"
|
||||
backHref="/picture"
|
||||
>
|
||||
<!-- Prompt -->
|
||||
{#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"
|
||||
>
|
||||
<Lightning size={12} weight="fill" class="text-primary" />
|
||||
{totalCredits}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<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 block text-sm font-medium text-foreground"> Prompt </label>
|
||||
<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 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"
|
||||
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>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
{#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>
|
||||
|
||||
{#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>
|
||||
{/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">
|
||||
<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-card px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/50 hover:text-foreground transition-colors"
|
||||
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>
|
||||
|
||||
<!-- 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
|
||||
<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"
|
||||
>
|
||||
</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>
|
||||
{#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"
|
||||
>
|
||||
{generationError}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</ModuleShell>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue