feat(profile,api): meImages foundation for AI reference generation (M1)

M1 of docs/plans/me-images-and-reference-generation.md — a user-owned
pool of reference images (face, fullbody, hands, …) that will back
image generation where the user appears as themselves (outfit try-on,
glasses, portraits) via OpenAI /v1/images/edits. Data layer only in
this commit; UI lands in M2, the edits endpoint in M3.

- Dexie v38: meImages table with id/kind/primaryFor/createdAt indices.
  Added to USER_LEVEL_TABLES so the hook stamps userId and skips the
  spaceId/authorId/visibility trio (one human = one face across every
  Space, not per-Space).
- Encryption registry: label + tags encrypted; kind/primaryFor/usage
  stay plaintext because they drive the indexed queries and the
  Reference picker's filtering. mediaId/URLs/dimensions are structural.
- Profile module store: createMeImage, updateMeImage,
  setAiReferenceEnabled (per-image KI opt-in — plan decision #5),
  setPrimary (transactional slot swap — only one row per primary slot),
  deleteMeImage. Emits MeImage* domain events.
- Queries: useAllMeImages, useMeImagesByKind, useReferenceImages
  (only the rows the user opted in for KI), useImageByPrimary.
- POST /api/v1/profile/me-images/upload: thin wrapper over mana-media
  with app='me' as the reference tag. No new MinIO bucket — plan
  decision #1 revised after verifying mana-media uses one bucket and
  only tags references by app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 13:50:53 +02:00
parent 32c95a3780
commit 89258eb451
10 changed files with 790 additions and 4 deletions

View file

@ -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 ────────────────────────────────────────────

View file

@ -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 };

View file

@ -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<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
@ -541,6 +542,16 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
],
},
// ─── 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<LocalMeImage>(['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<string, EncryptionConfig> = {
'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

View file

@ -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<string> = new Set([
'broadcastSettings',
'wetterSettings',
'userTagPresets',
'meImages',
]);
for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {

View file

@ -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<LocalUserContext>('userContext');
export const meImagesTable = db.table<LocalMeImage>('meImages');

View file

@ -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' }],
};

View file

@ -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<MeImage[]>(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<MeImage[]>(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<MeImage[]>(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<MeImage | null>(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);
}

View file

@ -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<MeImageUsage>;
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>): MeImageUsage {
return {
aiReference: override?.aiReference ?? false,
showInProfile: override?.showInProfile ?? true,
};
}
export const meImagesStore = {
async createMeImage(input: CreateMeImageInput): Promise<MeImage> {
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<Pick<LocalMeImage, 'label' | 'tags' | 'kind' | 'usage'>>
): Promise<void> {
const wrapped = { ...patch } as Record<string, unknown>;
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<void> {
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<void> {
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<void> {
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 });
},
};

View file

@ -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 210 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 ?? '',
};
}