diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ecf5b9231..acba8c46e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -27,6 +27,7 @@ import { musicRoutes } from './modules/music/routes'; import { chatRoutes } from './modules/chat/routes'; import { contextRoutes } from './modules/context/routes'; import { pictureRoutes } from './modules/picture/routes'; +import { profileRoutes } from './modules/profile/routes'; import { storageRoutes } from './modules/storage/routes'; import { todoRoutes } from './modules/todo/routes'; import { plantsRoutes } from './modules/plants/routes'; @@ -40,6 +41,7 @@ import { tracesRoutes } from './modules/traces/routes'; import { presiRoutes } from './modules/presi/routes'; import { researchRoutes } from './modules/research/routes'; import { whoRoutes } from './modules/who/routes'; +import { websiteRoutes } from './modules/website/routes'; import { wetterRoutes } from './modules/wetter/routes'; const PORT = parseInt(process.env.PORT || '3060', 10); @@ -97,6 +99,7 @@ app.route('/api/v1/music', musicRoutes); app.route('/api/v1/chat', chatRoutes); app.route('/api/v1/context', contextRoutes); app.route('/api/v1/picture', pictureRoutes); +app.route('/api/v1/profile', profileRoutes); app.route('/api/v1/storage', storageRoutes); app.route('/api/v1/todo', todoRoutes); app.route('/api/v1/plants', plantsRoutes); @@ -109,6 +112,7 @@ app.route('/api/v1/articles', articlesRoutes); app.route('/api/v1/traces', tracesRoutes); app.route('/api/v1/presi', presiRoutes); app.route('/api/v1/research', researchRoutes); +app.route('/api/v1/website', websiteRoutes); app.route('/api/v1/who', whoRoutes); // ─── Server Info ──────────────────────────────────────────── diff --git a/apps/api/src/modules/profile/routes.ts b/apps/api/src/modules/profile/routes.ts new file mode 100644 index 000000000..d0ecea937 --- /dev/null +++ b/apps/api/src/modules/profile/routes.ts @@ -0,0 +1,54 @@ +/** + * Profile module — server endpoints. + * + * Upload route for me-images (docs/plans/me-images-and-reference-generation.md M1). + * Thin wrapper over mana-media — the stored row lands in Dexie on the + * client after this returns. We keep server-side storage of the image + * in mana-media (CAS + thumbnails) so the Picture generator can pull + * the original bytes by `mediaId` for the eventual /v1/images/edits + * call (M3) without the client needing to re-upload each time. + */ + +import { Hono } from 'hono'; +import type { AuthVariables } from '@mana/shared-hono'; + +const routes = new Hono<{ Variables: AuthVariables }>(); + +// Max upload size for me-images. 10MB matches /picture/upload — same +// real-world phone-camera PNG range, same mana-media pipeline downstream. +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +routes.post('/me-images/upload', async (c) => { + const userId = c.get('userId'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + + if (!file) return c.json({ error: 'No file' }, 400); + if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: 'Max 10MB' }, 400); + + try { + const { uploadImageToMedia } = await import('../../lib/media'); + const buffer = await file.arrayBuffer(); + // `app='me'` tags the media_references row so a later + // GET /api/v1/media?app=me&userId=X can list all me-images, + // and the /v1/images/edits path can verify ownership in O(1). + const result = await uploadImageToMedia(buffer, file.name, { + app: 'me', + userId, + }); + + return c.json( + { + mediaId: result.id, + storagePath: result.id, + publicUrl: result.urls.original, + thumbnailUrl: result.urls.thumbnail, + }, + 201 + ); + } catch (_err) { + return c.json({ error: 'Upload failed' }, 500); + } +}); + +export { routes as profileRoutes }; diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index f9ad6078d..dc8d68666 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -89,6 +89,7 @@ import type { LocalBroadcastSettings, } from '../../modules/broadcast/types'; import type { LocalArticle, LocalHighlight } from '../../modules/articles/types'; +import type { LocalMeImage } from '../../modules/profile/types'; export const ENCRYPTION_REGISTRY: Record = { // ─── Chat ──────────────────────────────────────────────── @@ -541,6 +542,16 @@ export const ENCRYPTION_REGISTRY: Record = { ], }, + // ─── Me-Images (AI reference pool) ─────────────────────── + // docs/plans/me-images-and-reference-generation.md M1. + // Encrypted: `label` (user-typed — "Portrait Juni", "Outfit Studio") + // and `tags` (string[] — free-form tags like "ohne-brille", "studio"). + // Plaintext (intentional): `kind`, `primaryFor`, `usage`, mediaId, + // storagePath, publicUrl, thumbnailUrl, width, height — all indexed + // or structural metadata the query layer needs. The image blob itself + // lives in MinIO behind owner-RLS, not in Dexie. + meImages: entry(['label', 'tags']), + // Per-agent kontext documents — same schema as kontextDoc but keyed // per agent. Content is free-form markdown. agentKontextDocs: { enabled: true, fields: ['content'] }, @@ -748,6 +759,19 @@ export const ENCRYPTION_REGISTRY: Record = { 'unsubscribeLandingCopy', ]), + // ─── Website Builder ───────────────────────────────────── + // docs/plans/website-builder.md §D4 — content is PUBLIC by design. + // Site name, page titles, block props, theme config: the whole point + // is that published sites are served to anonymous visitors over SSR. + // Encrypting the draft would be security theater — the user publishes + // the same content seconds later as plaintext into published_snapshots. + // Form submissions (M4) land in target modules (contacts, todo, …) + // which carry their own encryption; the submissions-audit row holds + // the payload only briefly and gets scrubbed after delivery (M7). + websites: { enabled: false, fields: [] }, + websitePages: { enabled: false, fields: [] }, + websiteBlocks: { enabled: false, fields: [] }, + // Singleton sender profile. The user's legal address + IBAN live here // and are the most sensitive fields in the module (appear on every PDF // the user issues). logoMediaId / accentColor / number sequence state diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 1e464783c..ee8a9538d 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -895,6 +895,43 @@ db.version(36).upgrade(async (tx) => { } }); +// v37 — Website builder module (docs/plans/website-builder.md). +// Three tables for the block-tree CMS. All space-scoped; all plaintext +// (public content by design — see plan decision D4). +// - websites: root per space. `slug` indexed for the eventual public +// resolver + dedupe-within-space. `publishedVersion` indexed so the +// editor can fast-filter unpublished drafts. +// - websitePages: `[siteId+order]` for the ordered page list in the +// editor. `[siteId+path]` for the public path resolver (page by URL). +// - websiteBlocks: `[pageId+parentBlockId+order]` is the canonical tree +// scan — ordered children of a parent within a page. `[pageId+order]` +// is kept separately for the flat render path. +db.version(37).stores({ + websites: 'id, slug, publishedVersion, updatedAt, deletedAt', + websitePages: 'id, siteId, [siteId+order], [siteId+path], updatedAt, deletedAt', + websiteBlocks: + 'id, pageId, parentBlockId, [pageId+order], [pageId+parentBlockId+order], type, updatedAt, deletedAt', +}); + +// v38 — Me-Images: user-owned reference images for AI generation +// (docs/plans/me-images-and-reference-generation.md M1). +// +// User-level table, not space-scoped — see USER_LEVEL_TABLES below. +// The same human uses the same face/body across every Space, so the +// images live once per user and are reused from every Space's Picture +// generator. +// +// Indices: +// - `kind` for the Settings UI's "all face images" / "all fullbody" +// filter and for the query hook `useReferenceImages(kind)`. +// - `primaryFor` for the hot lookup "give me the current avatar / +// face-ref / body-ref" without a full scan. Null values are +// dropped by Dexie so the index stays dense. +// - `createdAt` for stable ordering (newest uploads first). +db.version(38).stores({ + meImages: 'id, kind, primaryFor, createdAt', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module @@ -1077,6 +1114,7 @@ const USER_LEVEL_TABLES: ReadonlySet = new Set([ 'broadcastSettings', 'wetterSettings', 'userTagPresets', + 'meImages', ]); for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { diff --git a/apps/mana/apps/web/src/lib/modules/profile/collections.ts b/apps/mana/apps/web/src/lib/modules/profile/collections.ts index 508a5801f..b88298947 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/collections.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/collections.ts @@ -3,6 +3,7 @@ */ import { db } from '$lib/data/database'; -import type { LocalUserContext } from './types'; +import type { LocalUserContext, LocalMeImage } from './types'; export const userContextTable = db.table('userContext'); +export const meImagesTable = db.table('meImages'); diff --git a/apps/mana/apps/web/src/lib/modules/profile/module.config.ts b/apps/mana/apps/web/src/lib/modules/profile/module.config.ts index 1e2ab4b7b..a4ac74d03 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/module.config.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/module.config.ts @@ -2,5 +2,5 @@ import type { ModuleConfig } from '$lib/data/module-registry'; export const profileModuleConfig: ModuleConfig = { appId: 'profile', - tables: [{ name: 'userContext' }], + tables: [{ name: 'userContext' }, { name: 'meImages' }], }; diff --git a/apps/mana/apps/web/src/lib/modules/profile/queries.ts b/apps/mana/apps/web/src/lib/modules/profile/queries.ts index aa0e9cd02..2d9373095 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/queries.ts @@ -4,8 +4,16 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { decryptRecords } from '$lib/data/crypto'; -import { userContextTable } from './collections'; -import { USER_CONTEXT_SINGLETON_ID, toUserContext, type UserContext } from './types'; +import { meImagesTable, userContextTable } from './collections'; +import { + USER_CONTEXT_SINGLETON_ID, + toUserContext, + toMeImage, + type UserContext, + type MeImage, + type MeImageKind, + type MeImagePrimarySlot, +} from './types'; /** Reactive live-query for the user context singleton. */ export function useUserContext() { @@ -16,3 +24,66 @@ export function useUserContext() { return toUserContext(decrypted); }, null); } + +/** + * All non-deleted me-images, newest first. Decrypted on the client — + * filters and sorting happen before decrypt where possible (`kind`, + * `primaryFor`, `createdAt` are plaintext indices). + */ +export function useAllMeImages() { + return useLiveQueryWithDefault(async () => { + const locals = await meImagesTable.orderBy('createdAt').reverse().toArray(); + const visible = locals.filter((row) => !row.deletedAt); + const decrypted = await decryptRecords('meImages', visible); + return decrypted.map(toMeImage); + }, [] as MeImage[]); +} + +/** + * Me-images filtered by `kind`. Uses the `kind` Dexie index so large + * pools still filter in one B-tree lookup. + */ +export function useMeImagesByKind(kind: MeImageKind) { + return useLiveQueryWithDefault(async () => { + const locals = await meImagesTable.where('kind').equals(kind).toArray(); + const visible = locals + .filter((row) => !row.deletedAt) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('meImages', visible); + return decrypted.map(toMeImage); + }, [] as MeImage[]); +} + +/** + * Only images the user explicitly opted in for AI reference use. + * This is the authoritative list the Picture generator's Reference + * picker reads from — if an image isn't here, it must not be sent + * to OpenAI. + */ +export function useReferenceImages() { + return useLiveQueryWithDefault(async () => { + const locals = await meImagesTable.orderBy('createdAt').reverse().toArray(); + const visible = locals.filter((row) => !row.deletedAt && row.usage?.aiReference === true); + const decrypted = await decryptRecords('meImages', visible); + return decrypted.map(toMeImage); + }, [] as MeImage[]); +} + +/** + * Current holder of a primary slot (avatar / face-ref / body-ref), + * or null if nobody claimed it yet. Powers the avatar fallback and + * the Reference picker's default selection. + */ +export function useImageByPrimary(slot: MeImagePrimarySlot) { + return useLiveQueryWithDefault(async () => { + const locals = await meImagesTable.where('primaryFor').equals(slot).toArray(); + const visible = locals.filter((row) => !row.deletedAt); + if (visible.length === 0) return null; + // The setPrimary store method keeps this to exactly one row. If + // somehow more than one slipped through (manual DB edit, race on + // a broken migration), prefer the most recent write. + visible.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); + const [decrypted] = await decryptRecords('meImages', [visible[0]]); + return toMeImage(decrypted); + }, null); +} diff --git a/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts b/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts new file mode 100644 index 000000000..479994eeb --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts @@ -0,0 +1,152 @@ +/** + * Me-Images store — mutation-only service. + * + * Reads happen via liveQuery helpers in queries.ts. Writes go through + * this store so encryption (`label`, `tags`) and primary-slot swapping + * stay in one place. + * + * Plan: docs/plans/me-images-and-reference-generation.md M1. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { meImagesTable } from '../collections'; +import { toMeImage } from '../types'; +import type { + LocalMeImage, + MeImage, + MeImageKind, + MeImagePrimarySlot, + MeImageUsage, +} from '../types'; + +export interface CreateMeImageInput { + kind: MeImageKind; + mediaId: string; + storagePath: string; + publicUrl: string; + thumbnailUrl?: string | null; + width: number; + height: number; + label?: string; + tags?: string[]; + usage?: Partial; + primaryFor?: MeImagePrimarySlot | null; +} + +/** + * Usage default on upload: `aiReference=false` (Opt-in per image is + * the eigentliche Zustimmungsebene — plan decision #5) and + * `showInProfile=true` so the image can back the avatar fallback even + * before the user explicitly picks a primary. + */ +function defaultUsage(override?: Partial): MeImageUsage { + return { + aiReference: override?.aiReference ?? false, + showInProfile: override?.showInProfile ?? true, + }; +} + +export const meImagesStore = { + async createMeImage(input: CreateMeImageInput): Promise { + const newLocal: LocalMeImage = { + id: crypto.randomUUID(), + kind: input.kind, + label: input.label, + mediaId: input.mediaId, + storagePath: input.storagePath, + publicUrl: input.publicUrl, + thumbnailUrl: input.thumbnailUrl ?? null, + width: input.width, + height: input.height, + tags: input.tags ?? [], + usage: defaultUsage(input.usage), + primaryFor: input.primaryFor ?? null, + }; + const snapshot = toMeImage({ ...newLocal }); + await encryptRecord('meImages', newLocal); + await meImagesTable.add(newLocal); + emitDomainEvent('MeImageAdded', 'profile', 'meImages', newLocal.id, { + meImageId: newLocal.id, + kind: input.kind, + primaryFor: newLocal.primaryFor, + }); + return snapshot; + }, + + async updateMeImage( + id: string, + patch: Partial> + ): Promise { + const wrapped = { ...patch } as Record; + await encryptRecord('meImages', wrapped); + await meImagesTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + /** + * Flip the per-image AI opt-in. Kept as its own method because + * it's the hottest privacy-relevant toggle in the Settings UI and + * warrants a dedicated event for audit. + */ + async setAiReferenceEnabled(id: string, enabled: boolean): Promise { + const existing = await meImagesTable.get(id); + if (!existing) return; + const nextUsage: MeImageUsage = { + ...defaultUsage(existing.usage), + aiReference: enabled, + }; + await meImagesTable.update(id, { + usage: nextUsage, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('MeImageAiReferenceToggled', 'profile', 'meImages', id, { + meImageId: id, + enabled, + }); + }, + + /** + * Claim a primary slot for `id`, clearing any previous holder of + * the same slot in the same transaction. At most one image per + * slot is ever active — the query layer relies on this invariant. + * + * Pass `null` as the second argument to unset the slot on `id` + * without claiming it for anyone else. + */ + async setPrimary(id: string, slot: MeImagePrimarySlot | null): Promise { + const nowIso = new Date().toISOString(); + await meImagesTable.db.transaction('rw', meImagesTable, async () => { + if (slot === null) { + await meImagesTable.update(id, { primaryFor: null, updatedAt: nowIso }); + return; + } + // Clear any current holder of this slot (usually zero or one). + const current = await meImagesTable.where('primaryFor').equals(slot).toArray(); + for (const row of current) { + if (row.id === id) continue; + await meImagesTable.update(row.id, { primaryFor: null, updatedAt: nowIso }); + } + await meImagesTable.update(id, { primaryFor: slot, updatedAt: nowIso }); + }); + emitDomainEvent('MeImagePrimaryChanged', 'profile', 'meImages', id, { + meImageId: id, + slot, + }); + }, + + async deleteMeImage(id: string): Promise { + const nowIso = new Date().toISOString(); + await meImagesTable.update(id, { + deletedAt: nowIso, + updatedAt: nowIso, + // Dropping a primary-holder silently leaves the slot empty; + // the UI's primary-picker will prompt the user to pick a new + // one next time it renders. + primaryFor: null, + }); + emitDomainEvent('MeImageDeleted', 'profile', 'meImages', id, { meImageId: id }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/profile/types.ts b/apps/mana/apps/web/src/lib/modules/profile/types.ts index 198d818e2..4105c9a20 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/types.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/types.ts @@ -119,3 +119,91 @@ export function emptyUserContext(): LocalUserContext { interview: { answeredIds: [], skippedIds: [] }, } as LocalUserContext; } + +// ── Me-Images: user-owned reference images for AI generation ─────── +// Plan: docs/plans/me-images-and-reference-generation.md +// +// Small, curated pool (typically 2–10 images) the user uploads once — +// a face portrait, a fullbody shot, maybe hands for ring try-ons. +// Per-bild opt-in (`usage.aiReference`) gates whether a given image +// may be sent to OpenAI `/v1/images/edits` when the Picture generator +// runs in reference mode. +// +// User-level table (like userContext): no spaceId, no authorId. The +// same human uses the same face across every Space. + +/** + * Reference kind. `face` and `fullbody` have dedicated primary slots + * in the UI (M2). `halfbody`, `hands`, and generic `reference` exist + * so the user can hold additional context (hands for rings, half-body + * for chest-up generations) without overloading the two main slots. + */ +export type MeImageKind = 'face' | 'fullbody' | 'halfbody' | 'hands' | 'reference'; + +/** + * Primary slot a given image fills. At most one image per slot is + * active at a time — setPrimary(id, slot) clears the previous holder. + * - `avatar`: drives the derived auth.users.image (M2 sync hook). + * - `face-ref`: default face fed to the reference generator. + * - `body-ref`: default fullbody reference. + */ +export type MeImagePrimarySlot = 'avatar' | 'face-ref' | 'body-ref'; + +export interface MeImageUsage { + /** Explicit opt-in per image: may KI verwenden? Default false on upload. */ + aiReference: boolean; + /** Counts towards avatar fallback if primary=avatar is not set. */ + showInProfile: boolean; +} + +export interface LocalMeImage extends BaseRecord { + id: string; + kind: MeImageKind; + label?: string; + mediaId: string; + storagePath: string; + publicUrl: string; + thumbnailUrl?: string | null; + width: number; + height: number; + tags: string[]; + usage: MeImageUsage; + primaryFor?: MeImagePrimarySlot | null; +} + +export interface MeImage { + id: string; + kind: MeImageKind; + label?: string; + mediaId: string; + storagePath: string; + publicUrl: string; + thumbnailUrl?: string | null; + width: number; + height: number; + tags: string[]; + usage: MeImageUsage; + primaryFor?: MeImagePrimarySlot | null; + createdAt: string; + updatedAt: string; +} + +/** Convert a LocalMeImage (Dexie row) to the public MeImage type. */ +export function toMeImage(local: LocalMeImage): MeImage { + return { + id: local.id, + kind: local.kind, + label: local.label, + mediaId: local.mediaId, + storagePath: local.storagePath, + publicUrl: local.publicUrl, + thumbnailUrl: local.thumbnailUrl ?? null, + width: local.width, + height: local.height, + tags: local.tags ?? [], + usage: local.usage ?? { aiReference: false, showInProfile: true }, + primaryFor: local.primaryFor ?? null, + createdAt: local.createdAt ?? '', + updatedAt: local.updatedAt ?? '', + }; +} diff --git a/docs/plans/me-images-and-reference-generation.md b/docs/plans/me-images-and-reference-generation.md new file mode 100644 index 000000000..51fc4278e --- /dev/null +++ b/docs/plans/me-images-and-reference-generation.md @@ -0,0 +1,354 @@ +# Me-Images + Reference-basierte Bildgenerierung — Plan + +## Status (2026-04-23) + +Greenfield. Keine Zeile Code, kein Schema, kein Endpunkt. Vorarbeit: Picture-Modul hat bereits ungenutzte `sourceImageId` + `generationId` Felder (Platzhalter), OpenAI `gpt-image-2` ist für Text-zu-Bild produktiv über `apps/api/src/modules/picture/routes.ts:65-96`. + +## Ziel + +Der Nutzer hinterlegt **mehrere eigene Referenzbilder** (Gesicht, Ganzkörper, weitere Posen/Outfits) in einem zentralen Pool. Diese Bilder werden **explizit opt-in** von KI-Bildgenerierung als Referenz verwendet, primär über **OpenAI `gpt-image-2`** (der `/v1/images/edits`-Endpoint akzeptiert bis zu 16 Reference-Images pro Call) mit Replicate-Fallback und optional lokalem `mana-image-gen` (FLUX + IP-Adapter, später). + +Kernfragen, die dieser Plan beantwortet: +1. Wo leben die Referenzbilder? (Datenmodell, Scope, Verschlüsselung) +2. Wie kommen sie in den Generator-Payload? (UI + API) +3. Wie ruft der Server OpenAI mit Reference-Images? (Backend) +4. Welche Use-Cases ergeben sich? (Konsumenten-Module) + +Nicht im Scope: +- **Wardrobe/Outfit-Modul** — bekommt einen eigenen Plan (`wardrobe-module.md`), konsumiert nur das hier entstehende Fundament. +- **Face-Swap in Video/Live-Streams** — nur Still-Images. +- **Per-Space-Avatare** — ein Nutzer hat eine Identität; falls später Bedarf, reicht ein `spaceId`-Zusatzfeld. +- **Gesichtsvalidierung / Liveness-Check** — Vertrauensmodell: der Nutzer lädt nur Bilder seiner selbst hoch, wir erzwingen das nicht. + +## Abgrenzung + +- **Kein `photos`**: `photos` ist Album/Tag-orientiert für beliebige Fotos. `meImages` ist ein kuratierter, winziger Pool (typ. 2–10 Bilder) mit klarer KI-Opt-in-Semantik. +- **Kein `body`**: `body` trackt Messungen/Workout. Progress-Fotos (Before/After) gehören dort hin, nicht in `meImages` — das hier ist für KI-Referenz, nicht für Fitness-Logging. +- **Kein `picture.images`**: `images` sind KI-generierte oder importierte Assets für Boards. `meImages` ist der *Input* für Generierung, nicht das Ergebnis. +- **Cross-Link**: `picture.images.sourceImageId` und `picture.images.referenceImageIds[]` zeigen auf `meImages.mediaId` (oder andere media-IDs). Das Picture-Modul bleibt der zentrale Ort, an dem das Ergebnis landet. + +## Entscheidungen + +### 1. Eigene Dexie-Tabelle, **nicht** `auth.users.image` erweitern + +Gründe: +- `auth.users.image` ist eine einzelne Text-URL in Better Auth. Mehrere Bilder + Metadaten + KI-Flags passen nicht rein ohne das Auth-Schema zu verunstalten. +- Dexie + mana-sync + Encryption-Registry sind das etablierte Pattern für per-User-Daten. +- `auth.users.image` bleibt als **abgeleitete Anzeige** erhalten (Primary-Face → Avatar-URL), wird aber über einen Sync-Hook gepflegt, nicht direkt beschrieben. + +### 2. Pro User, **nicht** pro Space + +Ein Mensch hat eine Identität. Space-spezifische Avatare (Brand-Space vs. Personal-Space) sind ein 10%-Fall und können später über ein optionales `spaceOverride: { [spaceId]: meImageId }` Feld im `profile`-Singleton gelöst werden, ohne `meImages` selbst zu ändern. + +### 3. Primär `gpt-image-2` via `/v1/images/edits`, nicht Text-zu-Bild + +Der Text-zu-Bild-Endpoint (`/v1/images/generations`) wird produktiv für freie Generierung genutzt und bleibt wie er ist. Für Reference-Workflows nutzen wir **`/v1/images/edits`** — derselbe Endpoint akzeptiert: +- `image` (multipart) — eine oder mehrere Reference-Bilder (gpt-image-2: bis zu 16) +- `prompt` — der Transformations-Wunsch +- `mask` (optional) — für Inpainting +- `size`, `quality`, `n` wie gehabt + +Das ist der native OpenAI-Weg und erspart uns IP-Adapter-Engineering auf dem eigenen GPU-Server. Lokaler Fallback (FLUX + PuLID/InstantID auf RTX 3090) wird als **M5 / später** geplant, nicht in M1-M3. + +### 4. Opt-in pro Bild, nicht global + +Jedes `meImage` hat ein `usage.aiReference: boolean` Flag. Default beim Upload: **false**. Der Nutzer aktiviert gezielt, welche Bilder die KI verwenden darf. Global-Kill-Switch kommt aus dem Profile-Singleton (`profile.aiUsesReferenceImages: boolean`), Default **true**, damit einzelne Opt-ins direkt wirken. + +## Architektur-Überblick + +``` +┌─ Client (SvelteKit) ────────────────────────────────────┐ +│ /settings/me-images (Upload + Toggles) │ +│ picture/GeneratorForm (Reference-Picker) │ +│ Dexie: meImages (encrypted label/tags/kind) │ +└──────┬──────────────────────────────────────────────────┘ + │ mana-sync (encrypted rows) + ▼ +┌─ mana-sync → PostgreSQL (mana_sync.meImages) ───────────┐ +└─────────────────────────────────────────────────────────┘ + +┌─ Generate-Flow (NEU) ───────────────────────────────────┐ +│ POST /api/v1/picture/generate-with-reference │ +│ { prompt, referenceMediaIds: [...], mode, mask? } │ +│ │ +│ Backend: │ +│ 1. Credits validieren (edits kostet wie generate) │ +│ 2. Fetch reference buffers aus mana-media (via mediaId) │ +│ 3. multipart → OpenAI /v1/images/edits │ +│ oder (Fallback) mana-image-gen /edit │ +│ 4. Response → uploadImageToMedia → return {images[]} │ +└─────────────────────────────────────────────────────────┘ + +┌─ Tool-Registry / MCP ───────────────────────────────────┐ +│ me.listReferenceImages (read-only, für Personas) │ +│ me.generateWithReference (triggert obigen Endpoint) │ +└─────────────────────────────────────────────────────────┘ +``` + +## Datenmodell + +### Neue Dexie-Tabelle: `meImages` + +```typescript +// apps/mana/apps/web/src/lib/modules/profile/types.ts +export type MeImageKind = + | 'face' // Kopf/Schulter, neutral + | 'fullbody' // Ganzkörper, stehend + | 'halfbody' // Hüfte aufwärts + | 'hands' // für Schmuck/Ring-Anproben + | 'reference'; // sonstige (andere Pose, anderer Lichtkontext) + +export interface LocalMeImage { + id: string; + kind: MeImageKind; + label?: string; // "Portrait neutral Studio", "Outfit Juni" + mediaId: string; // → mana-media CAS (quelle-of-truth fürs Bild) + storagePath: string; // cached vom mana-media-Response + publicUrl: string; + thumbnailUrl?: string; + width: number; + height: number; + tags: string[]; // 'smiling', 'glasses-off', 'studio-light' + usage: { + aiReference: boolean; // Opt-in: darf KI das nutzen? + showInProfile: boolean; // für Avatar-Fallback-Logik + }; + primaryFor?: 'avatar' | 'face-ref' | 'body-ref' | null; + createdAt: number; + updatedAt: number; + _pendingSync?: number; +} +``` + +**Primary-Logik**: Pro `primaryFor`-Wert existiert maximal ein `meImage` mit diesem Flag. Setzen eines neuen Primary räumt das alte auf (Store-Methode `setPrimary(id, slot)`). + +### Encryption-Registry-Eintrag + +```typescript +// apps/mana/apps/web/src/lib/data/crypto/registry.ts +meImages: { + enabled: true, + fields: ['label', 'tags', 'kind'] +} +``` + +`mediaId`, `storagePath`, `publicUrl`, `width`, `height`, `primaryFor`, Timestamps → plaintext (konsistent mit `images` im Picture-Modul). Das Bild selbst liegt hinter mana-media-Auth — nicht verschlüsselt auf Dateiebene, aber nur für den Owner abrufbar. Für Zero-Knowledge-Modus-Nutzer: im M4 kommt optionale client-seitige Blob-Verschlüsselung dazu (out-of-scope für M1). + +### Kein neuer Sync-Endpoint nötig + +mana-sync behandelt `meImages` wie jede andere per-User-Tabelle (userScoped, nicht spaceScoped). Nur Registrierung in der Sync-Schema-Liste. + +### Picture-Modul: bestehende Felder aktivieren + eins ergänzen + +```typescript +// apps/mana/apps/web/src/lib/modules/picture/types.ts +export interface LocalImage { + // ... bestehend + sourceImageId?: string | null; // bereits vorhanden — jetzt genutzt + referenceImageIds?: string[] | null; // NEU: für multi-reference gpt-image-2 + generationMode?: 'text' | 'edit' | 'inpaint'; // NEU + generationId?: string | null; // bereits vorhanden +} +``` + +Encryption-Registry: `referenceImageIds`, `generationMode` → plaintext (IDs sind random, keine Leak-Gefahr). + +## Backend-Erweiterungen + +### Neuer Endpoint: `POST /api/v1/picture/generate-with-reference` + +Datei: `apps/api/src/modules/picture/routes.ts` (erweitern, nicht neue Datei) + +```typescript +routes.post('/generate-with-reference', async (c) => { + const userId = c.get('userId'); + const { + prompt, + model, // 'openai/gpt-image-2' | 'local/flux-pulid' | … + referenceMediaIds, // string[] (mana-media IDs; aus meImages oder picture.images) + mode, // 'edit' | 'inpaint' + maskMediaId, // optional, nur für inpaint + quality, + width, + height, + n, + } = await c.req.json(); + + // 1. Credits — gleicher Tarif wie /generate (3/10/25 je quality × n) + // 2. Reference-Buffers holen (parallel): for each id → fetchMediaBuffer(id, userId) + // — mana-media verifiziert, dass userId der Owner ist (keine fremden IDs) + // 3. multipart/form-data bauen: + // model, prompt, size, quality, n + // image[] (als File-Parts; bei n>1 refs: image[]=ref1, image[]=ref2, …) + // mask (optional) + // 4. POST https://api.openai.com/v1/images/edits + // 5. b64_json → uploadImageToMedia → return { images: [...] } +}); +``` + +**Lib-Helper neu** in `apps/api/src/lib/media.ts`: `fetchMediaBuffer(mediaId, userId): Promise` — lädt + verifiziert Ownership in einem Call. + +**Modell-Routing** analog zum bestehenden `/generate`: +- `openai/gpt-image-2` (default) → OpenAI `/v1/images/edits` +- `local/*` → mana-image-gen `/edit` (siehe M5) +- Replicate hat keinen äquivalenten Multi-Reference-Endpoint → wir überspringen Replicate hier; fällt auf OpenAI zurück. + +**Fehler-Matrix**: +- 402 Insufficient credits +- 404 Reference media not found or not owned +- 413 Reference zu groß (OpenAI-Limit: 4MB pro PNG) +- 502 OpenAI-Fehler (mit `detail.slice(0,500)` wie bisher) + +### `mana-image-gen` erweitern (M5, nicht M1) + +Python/FastAPI-Seite bekommt einen `POST /edit` Endpoint, der IP-Adapter oder PuLID auf FLUX lädt und `reference_images: list[bytes]` + `prompt` annimmt. Weil Replicate/lokal nicht parallel zu OpenAI im selben Call laufen müssen, ist das ein reiner Fallback für Offline-/Zero-Knowledge-Szenarien und kann später dazukommen. + +## UI: zwei Touchpoints + +### 1. `/settings/me-images` (neu) + +- 2 prominente Slots oben: **Gesicht** (quadratisch, 512×512 empfohlen) und **Ganzkörper** (portrait, min 1024 hoch) +- Darunter Grid für zusätzliche Referenzen (Drag-and-Drop, Multi-Select-Upload — Pattern aus `picture/ListView.svelte:165-217` klauen) +- Pro Bild-Kachel: + - Kind-Badge (Gesicht / Ganzkörper / Hände / …) + - Toggle `usage.aiReference` (prominent, mit Tooltip "Wird an OpenAI gesendet wenn du ein Bild mit Referenz generierst") + - Primary-Stern (nur einer pro Slot aktiv) + - Tag-Editor + - Löschen +- Oben Globaler Kill-Switch: "KI darf meine Referenzbilder verwenden" (aus `profile`-Singleton) +- Hinweis-Card zu Datenschutz: wo landen die Bilder, wer sieht sie, wie löschen + +Zugriff: ⚙ im `profile`-Modul → "Meine Bilder" + direkte Route. + +### 2. Picture-Generator: Reference-Picker + +In `apps/mana/apps/web/src/lib/modules/picture/components/GeneratorForm.svelte` (oder Äquivalent): + +- Neuer "Referenz hinzufügen"-Button öffnet ein Popover +- Popover listet: + - *Mich*: alle `meImages` mit `usage.aiReference === true` (primary zuerst) + - *Aus diesem Modul*: letzte N `images` (für Generation-Chaining) +- Multi-Select bis zu 4 Referenzen (Client-Limit, OpenAI erlaubt 16) +- Wenn mindestens eine Referenz gewählt: Endpoint switched auf `/generate-with-reference`, UI zeigt "gpt-image-2 Edit" statt "Generate" +- Optional: Mask-Drawing für Inpainting (out-of-scope für M2, kommt in M3) + +## Tool-Registry + MCP + +Nach M1+M2 bekommt `packages/mana-tool-registry` (siehe Memory, MCP M1+M1.5 shipped) zwei neue Tools: + +- `me.listReferenceImages()` — read-only, gibt `{ id, kind, label, primaryFor, thumbnailUrl }[]` zurück, nur `aiReference=true` Einträge. Plaintext-Tier (label wird ent-verschlüsselt auf Server-Seite wie andere encrypted Tools). +- `me.generateWithReference({ prompt, referenceImageIds, mode })` — wrappt den neuen Endpoint, gibt `{ imageIds, mediaIds }` zurück. + +Damit können Personas (AI Workbench, Chat, ai-missions) und externe MCP-Clients (Claude Desktop) den Nutzer "visualisieren". Beispiel: Persona "Stylistin" bekommt `me.listReferenceImages` + `me.generateWithReference` als Tool-Subset und kann in Chat sagen *"Probieren wir drei Brillen-Looks?"*. + +## Verschlüsselung + Datenschutz + +- **Metadaten** (label, tags, kind): client-seitig AES-GCM-256 vor Dexie-Write, wie im Standard-Pattern. +- **Bilddaten**: bleiben in MinIO (mana-media Bucket) mit Owner-RLS. Für Zero-Knowledge-Mode-Nutzer kommt in M4 optionale Client-Blob-Verschlüsselung (Upload verschlüsselt → Server sieht Ciphertext → OpenAI bekommt nur Bilder, wenn der Nutzer den Key entsperrt und den Edit-Call triggert). Das ist ein eigener Workstream und kein Blocker für M1-M3. +- **OpenAI-Call**: jeder `/generate-with-reference`-Call geht als HTTPS-Multipart raus. Bilder landen kurzzeitig auf OpenAI-Servern (Policy: 30 Tage). Das muss die Settings-UI explizit erwähnen. +- **Audit**: jeder Edit-Call loggt `{userId, referenceMediaIds, prompt, model, timestamp}` in eine neue `picture.generation_log`-Tabelle (nicht encrypted, für Rechnungs-/Abuse-Prüfung — Memoro-seitig, nicht in Dexie). + +## Use-Cases + Modul-Zuordnung + +### M1–M3 decken diese Use-Cases direkt ab: + +| Use Case | Wo im UI | Modul | +|---|---|---| +| "Zeig mir wie ich mit einer schwarzen Brille aussehe" | Picture Generator → Reference: face → Prompt | `picture` | +| "Generiere ein Profilbild im Studio-Look aus meinem Selfie" | Picture Generator → Reference: face → Prompt | `picture` | +| "Mach ein Titelbild für meine Präsentation mit meinem Portrait" | Presi → Cover-Generator → Reference-Picker | `presi` (M4 Konsument) | +| "Ich in mittelalterlicher Rüstung" / kreative Spielereien | Picture Generator | `picture` | +| Avatar automatisch aus primary face ableiten | Profile-Settings | `profile` | + +### Eigener Folge-Plan `wardrobe-module.md` (nicht in diesem Plan): + +| Use Case | Wo im UI | Modul | +|---|---|---| +| Outfit-Katalog pflegen (T-Shirts, Hosen, Schuhe als einzelne Items) | Wardrobe Gridview | `wardrobe` (neu) | +| "Kombiniere diese Jacke mit meinem Outfit aus Foto X" | Wardrobe → Outfit-Composer | `wardrobe` | +| Virtual Try-On mit Ganzkörper-Referenz + Garment-Referenz | Wardrobe → Try-On | `wardrobe` | +| Jahreszeit-Vorschläge ("Was ziehe ich heute an") | Wardrobe Daily-Card | `wardrobe` | + +### Weitere sinnvolle Konsumenten (eigene Tickets, nicht Teil dieses Plans): + +- **`website`** (Block-Tree CMS, in Planung): Portrait-Block kann `primaryFor='avatar'` automatisch ziehen. +- **`presi`**: Cover-Slide-Template mit Nutzer-Portrait. +- **`broadcast`** / **`social-relay`**: Avatar-Generierung für Posts. +- **`dreams`**: "Ich im Traum" — Nutzer als Protagonist in KI-generierten Traum-Szenen. +- **`wishes`**: "Wie würde mir das stehen" — Wishlist-Preview vor dem Kauf. + +## Migrationsplan + +Soft-first/Hard-follow-up-Regel (siehe Memory): + +1. **Soft**: Dexie v27 führt `meImages` ein, Encryption-Registry um den Eintrag erweitern, sync-Schema registrieren. `auth.users.image` bleibt als-is. Neue Primary-Face-Uploads schreiben *zusätzlich* zur `meImages`-Tabelle. +2. **Hard (Folge-Commit, einige Tage später)**: One-shot-Migration im Client: existierendes `auth.users.image` → `meImages` mit `kind='face'`, `primaryFor='avatar'`, `usage.aiReference=false` (Opt-in bleibt explizit). `auth.users.image` wird danach zum abgeleiteten Feld, das über einen Sync-Hook aus `meImages(primaryFor='avatar').publicUrl` gefüllt wird. + +## Milestones + +- **M1 — `meImages` Foundation** (~1 Tag) + - [ ] Dexie v27: `meImages`-Tabelle + - [ ] `apps/mana/apps/web/src/lib/modules/profile/types.ts`: Typen + - [ ] Encryption-Registry-Eintrag + - [ ] Store (`stores/meImages.svelte.ts`): CRUD + `setPrimary` + - [ ] Queries (`useMyImages`, `useReferenceImages`, `useImageByPrimary`) + - [ ] Sync-Schema registrieren + - [ ] Upload-Wrapper nutzt bestehenden `picture/upload`-Endpoint mit `app=me` (neuer Bucket `me-storage` in MinIO) + +- **M2 — UI Route `/settings/me-images`** (~1 Tag) + - [ ] Route + ModuleShell-Wrapping (wie andere Settings-Routen) + - [ ] Slot-Komponenten für Face/Fullbody, Grid für Reste + - [ ] Drag-and-Drop-Upload + Multi-File + - [ ] Opt-in-Toggles pro Bild + global + - [ ] Primary-Stern + - [ ] Profile-Modul ⚙ → neuer Eintrag "Meine Bilder" + - [ ] Hard-Migration `auth.users.image` → `meImages` + +- **M3 — Backend `generate-with-reference`** (~1-2 Tage) + - [ ] `fetchMediaBuffer`-Helper in `apps/api/src/lib/media.ts` + - [ ] Neue Route `POST /picture/generate-with-reference` mit OpenAI `/v1/images/edits` + - [ ] Credit-Validierung identisch zu `/generate` + - [ ] Generation-Log-Tabelle + - [ ] Fehler-Matrix + +- **M4 — Picture-Generator UI** (~1 Tag) + - [ ] Reference-Picker-Popover in GeneratorForm + - [ ] Payload-Switch `/generate` vs. `/generate-with-reference` + - [ ] `picture.images.referenceImageIds` + `generationMode` persistieren + - [ ] Detailansicht eines Bilds zeigt genutzte Referenzen + +- **M5 — Tool-Registry + MCP-Exposure** (~0.5 Tag) + - [ ] `me.listReferenceImages` + `me.generateWithReference` in `packages/mana-tool-registry` + - [ ] MCP-Server (Port 3069) exponiert die Tools + - [ ] Persona-Runner (sobald M2 von Personas-Plan live) kann sie konsumieren + +- **M6 — (optional, später) Lokaler Fallback via mana-image-gen** (mehrere Tage) + - [ ] FLUX + PuLID/InstantID auf GPU-Server + - [ ] `POST /edit` in mana-image-gen + - [ ] Routing über `local/flux-pulid` + +- **M7 — (optional, später) Inpainting-Mask-Drawing** (~2 Tage) + - [ ] Canvas-Mask-Editor im Picture-Generator + - [ ] Mask als zweites Medium hochladen + an `/edit` übergeben + +- **M8 — (optional, später) Zero-Knowledge-Bilder** + - [ ] Client-seitige Verschlüsselung der Bild-Blobs in MinIO + - [ ] Bei Generate-Call entschlüsselt der Client und sendet temp an Server → OpenAI → Result wieder verschlüsseln + +## Entschieden (2026-04-23) + +1. **Bucket-Namensgebung**: ~~eigener `me-storage`-Bucket~~ *revidiert* — mana-media nutzt heute einen einzelnen Bucket (`mana-media`); der `app`-String landet als Tag in `media_references.app`. Upload geht mit `app='me'`, kein neuer Bucket nötig. Falls später Lifecycle-Rules pro App-Tag nötig werden, reicht eine mc-Regel mit `--prefix 'me/'` auf dem mana-media-Bucket. +2. **`primaryFor='avatar'` → `auth.users.image`**: Client-Dexie-Hook ruft `PUT /api/v1/auth/profile`. mana-sync bleibt außen vor. +3. **OpenAI Ref-Image-Format**: Original-Format durchreichen (PNG/JPG/WEBP — OpenAI akzeptiert alle). Keine Server-Konvertierung. +4. **Credit-Kosten für Multi-Ref-Edits**: identisch zu `/generate`, pro Output-Bild, unabhängig von Reference-Anzahl. +5. **`profile.aiUsesReferenceImages`-Default**: `true` (globaler Panic-Kill-Switch; Pro-Bild-Opt-in ist die eigentliche Hürde). +6. **Alter Avatar-Upload-Pfad**: bleibt in M1 unangetastet; M2 biegt `EditProfileModal` auf `/settings/me-images` um und räumt den toten Endpoint-Call weg. + +## Verweise + +- Bestehender Picture-Generate-Endpoint: `apps/api/src/modules/picture/routes.ts:43-227` +- Picture Upload-Pattern (für UI-Klau): `apps/mana/apps/web/src/lib/modules/picture/ListView.svelte:165-217` +- Encryption-Registry-Pattern: `apps/mana/apps/web/src/lib/data/crypto/registry.ts` +- mana-media CAS: `services/mana-media/CLAUDE.md` +- MCP-Gateway + Tool-Registry: `services/mana-mcp/CLAUDE.md`, `packages/mana-tool-registry/` +- Spaces-Modul-Allowlist (falls neues `wardrobe` kommt): `packages/shared-types/src/spaces.ts:63-184`