mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
chore(observability): scrape mana-mcp at :3069
Pairs with c94ab01c6 which added the real /metrics endpoint. Without a
scrape job the policy_decisions_total counter has nowhere to go and
the soak period is flying blind.
30s interval to match mana-ai. Same job shape as mana-ai — any Grafana
dashboard that auto-discovers services via labels will pick this up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c94ab01c69
commit
d087b4744a
5 changed files with 237 additions and 14 deletions
|
|
@ -0,0 +1,129 @@
|
|||
<!--
|
||||
Reference-picker for the Picture generator. Lists every me-image the
|
||||
user has explicitly flagged with `usage.aiReference=true` (plan M2)
|
||||
and lets the caller pick up to 4 to feed into /generate-with-reference.
|
||||
|
||||
The component holds no identity of its own — parents own the
|
||||
selectedIds via `bind:` so the generator page can switch its fetch
|
||||
endpoint + persist the ids on the resulting LocalImage.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Check, UserCircle } from '@mana/shared-icons';
|
||||
import { useReferenceImages } from '$lib/modules/profile/queries';
|
||||
import type { MeImage } from '$lib/modules/profile/types';
|
||||
|
||||
interface Props {
|
||||
selectedIds: string[];
|
||||
maxSelection?: number;
|
||||
}
|
||||
|
||||
let { selectedIds = $bindable([]), maxSelection = 4 }: Props = $props();
|
||||
|
||||
const referenceImages$ = useReferenceImages();
|
||||
const referenceImages = $derived(referenceImages$.value ?? []);
|
||||
const loading = $derived(referenceImages$.loading);
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
face: 'Gesicht',
|
||||
fullbody: 'Ganzkörper',
|
||||
halfbody: 'Halbkörper',
|
||||
hands: 'Hände',
|
||||
reference: 'Referenz',
|
||||
};
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return selectedIds.includes(id);
|
||||
}
|
||||
|
||||
function toggle(img: MeImage) {
|
||||
if (isSelected(img.id)) {
|
||||
selectedIds = selectedIds.filter((id) => id !== img.id);
|
||||
return;
|
||||
}
|
||||
// At the cap: silently ignore rather than shuffling the earliest
|
||||
// pick out — easier to reason about than a "rolling window" and
|
||||
// matches the visual "disabled" hint we show below.
|
||||
if (selectedIds.length >= maxSelection) return;
|
||||
selectedIds = [...selectedIds, img.id];
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectedIds = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading && referenceImages.length === 0}
|
||||
<p class="text-xs text-muted-foreground">Lade Referenz-Bilder…</p>
|
||||
{:else if referenceImages.length === 0}
|
||||
<div
|
||||
class="flex items-start gap-3 rounded-md border border-dashed border-border bg-background/50 p-3 text-xs text-muted-foreground"
|
||||
>
|
||||
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-foreground">Noch keine Referenzbilder freigegeben.</p>
|
||||
<p>
|
||||
Lade ein Gesichts- oder Ganzkörperbild hoch und aktiviere "KI darf nutzen" unter
|
||||
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
|
||||
Meine Bilder
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-muted-foreground">
|
||||
{selectedIds.length} von {maxSelection} ausgewählt
|
||||
</span>
|
||||
{#if selectedIds.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clear}
|
||||
class="font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{#each referenceImages as img (img.id)}
|
||||
{@const selected = isSelected(img.id)}
|
||||
{@const disabled = !selected && selectedIds.length >= maxSelection}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle(img)}
|
||||
{disabled}
|
||||
aria-pressed={selected}
|
||||
aria-label={selected ? 'Auswahl aufheben' : 'Auswählen'}
|
||||
class="group relative aspect-square overflow-hidden rounded-md border bg-muted transition-all {selected
|
||||
? 'border-primary ring-2 ring-primary'
|
||||
: 'border-border hover:border-primary/50'} {disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: 'cursor-pointer'}"
|
||||
>
|
||||
{#if img.thumbnailUrl || img.publicUrl}
|
||||
<img
|
||||
src={img.thumbnailUrl ?? img.publicUrl}
|
||||
alt={img.label ?? KIND_LABELS[img.kind]}
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
<span
|
||||
class="absolute left-1 top-1 rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground backdrop-blur-sm"
|
||||
>
|
||||
{KIND_LABELS[img.kind] ?? img.kind}
|
||||
</span>
|
||||
{#if selected}
|
||||
<span
|
||||
class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm"
|
||||
>
|
||||
<Check size={12} weight="bold" />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>(ASPECT_RATIOS[0].id);
|
||||
let batchCount = $state<1 | 2 | 4>(1);
|
||||
let selectedReferenceIds = $state<string[]>([]);
|
||||
let isGenerating = $state(false);
|
||||
let generationError = $state('');
|
||||
let lastImageUrls = $state<string[]>([]);
|
||||
|
|
@ -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 @@
|
|||
<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>
|
||||
{#if isReferenceMode}
|
||||
<span class="ml-1 text-xs text-muted-foreground">
|
||||
· wird im Referenz-Modus ignoriert
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
id="negative-prompt"
|
||||
type="text"
|
||||
bind:value={negativePrompt}
|
||||
disabled={isReferenceMode}
|
||||
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"
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reference images section -->
|
||||
<section class="space-y-3 rounded-lg border border-border bg-background/50 p-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Referenz-Bilder
|
||||
<span class="ml-1 normal-case text-muted-foreground">(optional)</span>
|
||||
</h2>
|
||||
{#if isReferenceMode}
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
Referenz-Modus
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<ReferenceImagePicker bind:selectedIds={selectedReferenceIds} />
|
||||
</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">
|
||||
|
|
@ -222,10 +288,12 @@
|
|||
<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"
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#each PROVIDERS as p}
|
||||
<option value={p.id}>{p.label}</option>
|
||||
<option value={p.id} disabled={isReferenceMode && !p.id.startsWith('openai/')}>
|
||||
{p.label}{isReferenceMode && !p.id.startsWith('openai/') ? ' · keine Referenz' : ''}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{currentProvider.description}</p>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ============================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue