diff --git a/apps/mana/apps/web/src/lib/modules/picture/components/ReferenceImagePicker.svelte b/apps/mana/apps/web/src/lib/modules/picture/components/ReferenceImagePicker.svelte new file mode 100644 index 000000000..afc4ddddb --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/picture/components/ReferenceImagePicker.svelte @@ -0,0 +1,129 @@ + + + +{#if loading && referenceImages.length === 0} +

Lade Referenz-Bilder…

+{:else if referenceImages.length === 0} +
+ +
+

Noch keine Referenzbilder freigegeben.

+

+ Lade ein Gesichts- oder Ganzkörperbild hoch und aktiviere "KI darf nutzen" unter + + Meine Bilder + . +

+
+
+{:else} +
+
+ + {selectedIds.length} von {maxSelection} ausgewählt + + {#if selectedIds.length > 0} + + {/if} +
+ +
+ {#each referenceImages as img (img.id)} + {@const selected = isSelected(img.id)} + {@const disabled = !selected && selectedIds.length >= maxSelection} + + {/each} +
+
+{/if} diff --git a/apps/mana/apps/web/src/lib/modules/picture/queries.ts b/apps/mana/apps/web/src/lib/modules/picture/queries.ts index 42b255ae5..82684c2e1 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/queries.ts @@ -45,6 +45,8 @@ export function toImage(local: LocalImage): Image { isArchived: local.isArchived ?? undefined, generationId: local.generationId ?? undefined, sourceImageId: local.sourceImageId ?? undefined, + referenceImageIds: local.referenceImageIds ?? undefined, + generationMode: local.generationMode ?? undefined, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; diff --git a/apps/mana/apps/web/src/lib/modules/picture/types.ts b/apps/mana/apps/web/src/lib/modules/picture/types.ts index 5110b1715..b4a239be6 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/types.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/types.ts @@ -4,6 +4,14 @@ import type { BaseRecord } from '@mana/local-store'; +/** + * How the image was created. 'text' is the classic prompt-only + * generation via /picture/generate; 'reference' is a multi-image edit + * via /picture/generate-with-reference (plan M3) — the latter carries + * `referenceImageIds` pointing at the meImages that fed the edit. + */ +export type ImageGenerationMode = 'text' | 'reference'; + export interface LocalImage extends BaseRecord { prompt: string; negativePrompt?: string | null; @@ -24,6 +32,9 @@ export interface LocalImage extends BaseRecord { isArchived?: boolean; generationId?: string | null; sourceImageId?: string | null; + /** mana-media ids of the me-images that fed a reference-edit. */ + referenceImageIds?: string[] | null; + generationMode?: ImageGenerationMode | null; } export interface LocalBoard extends BaseRecord { @@ -83,6 +94,8 @@ export interface Image { isArchived?: boolean; generationId?: string; sourceImageId?: string; + referenceImageIds?: string[]; + generationMode?: ImageGenerationMode; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/routes/(app)/picture/generate/+page.svelte b/apps/mana/apps/web/src/routes/(app)/picture/generate/+page.svelte index 9af360f1a..079059a55 100644 --- a/apps/mana/apps/web/src/routes/(app)/picture/generate/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/picture/generate/+page.svelte @@ -5,6 +5,7 @@ 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 ReferenceImagePicker from '$lib/modules/picture/components/ReferenceImagePicker.svelte'; import { ModuleShell } from '$lib/components/shell'; type ProviderOption = { @@ -42,6 +43,7 @@ let quality = $state<'low' | 'medium' | 'high'>('medium'); let aspectId = $state(ASPECT_RATIOS[0].id); let batchCount = $state<1 | 2 | 4>(1); + let selectedReferenceIds = $state([]); let isGenerating = $state(false); let generationError = $state(''); let lastImageUrls = $state([]); @@ -55,6 +57,24 @@ currentProvider.supportsQuality ? creditsPerImage * effectiveBatch : 10 ); + // Reference edits only work through OpenAI — the plan (M3) rejects + // non-OpenAI models server-side. Flip the model automatically the + // first time the user adds a reference so they don't get a 400 at + // submit time; flipping back is their choice. + const isReferenceMode = $derived(selectedReferenceIds.length > 0); + $effect(() => { + if (isReferenceMode && !modelId.startsWith('openai/')) { + modelId = 'openai/gpt-image-2'; + } + }); + + /** Map the current aspect-ratio tuple to OpenAI's size literal. */ + function resolveSize(width: number, height: number): '1024x1024' | '1536x1024' | '1024x1536' { + if (width > height) return '1536x1024'; + if (height > width) return '1024x1536'; + return '1024x1024'; + } + async function handleGenerate() { if (!prompt.trim()) return; @@ -66,21 +86,39 @@ const token = await authStore.getValidToken(); if (!token) throw new Error('Nicht angemeldet'); - const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate`, { + // Two server paths: /generate is text-to-image, the M3 edit + // endpoint takes reference mediaIds + forwards them multipart + // to OpenAI /v1/images/edits. Which one we hit is purely + // driven by whether the user added references. + const endpoint = isReferenceMode + ? '/api/v1/picture/generate-with-reference' + : '/api/v1/picture/generate'; + const body = isReferenceMode + ? { + prompt: prompt.trim(), + referenceMediaIds: selectedReferenceIds, + model: modelId, + quality: currentProvider.supportsQuality ? quality : undefined, + size: resolveSize(currentAspect.width, currentAspect.height), + n: effectiveBatch, + } + : { + prompt: prompt.trim(), + negativePrompt: negativePrompt.trim() || undefined, + model: modelId, + quality: currentProvider.supportsQuality ? quality : undefined, + width: currentAspect.width, + height: currentAspect.height, + n: effectiveBatch, + }; + + const res = await fetch(`${getManaApiUrl()}${endpoint}`, { 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, - }), + body: JSON.stringify(body), }); if (!res.ok) { @@ -111,7 +149,7 @@ const local: LocalImage = { id: crypto.randomUUID(), prompt: data.prompt, - negativePrompt: negativePrompt.trim() || null, + negativePrompt: isReferenceMode ? null : negativePrompt.trim() || null, model: data.model, publicUrl: img.imageUrl, storagePath: img.mediaId ?? img.imageUrl, @@ -122,6 +160,8 @@ isPublic: false, isFavorite: false, downloadCount: 0, + generationMode: isReferenceMode ? 'reference' : 'text', + referenceImageIds: isReferenceMode ? [...selectedReferenceIds] : null, createdAt: now, updatedAt: now, }; @@ -197,17 +237,43 @@
+ +
+
+

+ Referenz-Bilder + (optional) +

+ {#if isReferenceMode} + + Referenz-Modus + + {/if} +
+

+ Wähle bis zu 4 deiner freigegebenen Bilder — das erzeugte Bild wird dich enthalten + (Outfit-Anprobe, Brillen, Frisuren). Läuft über OpenAI gpt-image-2. +

+ +
+

@@ -222,10 +288,12 @@

{currentProvider.description}

diff --git a/docker/prometheus/prometheus.yml b/docker/prometheus/prometheus.yml index 96f74e18d..996ef20eb 100644 --- a/docker/prometheus/prometheus.yml +++ b/docker/prometheus/prometheus.yml @@ -132,6 +132,17 @@ scrape_configs: metrics_path: '/metrics' scrape_interval: 30s + # Mana MCP Gateway (Bun, :3069) — exposes the shared tool-registry + # over Streamable HTTP to external agents. Emits policy-gate + # decisions (`mana_mcp_policy_decisions_total{decision,reason,mode}`) + # and per-tool invocation metrics. Critical during the POLICY_MODE + # log-only soak period to decide when it's safe to flip to enforce. + - job_name: 'mana-mcp' + static_configs: + - targets: ['mana-mcp:3069'] + metrics_path: '/metrics' + scrape_interval: 30s + # ============================================ # GPU Server (Windows PC, LAN: 192.168.178.11) # ============================================