Profile (3 Iterationen):
- app/profile/index.tsx + components/profile/* (Header, StatsBar, Approved,
Streak, UrgeStats, Demographics, DigaMissionBanner)
- echte Live-Daten via useMe-Hook (Avatar/Nickname/Plan/Email/Provider-Pill)
- Demographics mit echten Inputs (TextInput + Bottom-Sheet-Selects),
debounced auto-save, Pro-Trial-Reward-Banner, Mikro-Why-Texte
- Approved Domains als plain integer (KEIN Plan-Slot/Cap)
- Friendly Hint-Text statt Progress-Bar (alignSelf:'stretch' Pattern)
- StatsBar zentriert mit 3 prominenten Cards (vertikale Dividers)
- Cooldown-Timeline als Liste mit 1px-Rail
- ApprovedDomainsList: Collapse-Chevron rechts in Title-Row (Pattern-Fix)
- Eigene vs fremde Profile-Ansicht streng getrennt (DSGVO/Anonymität)
Header-Dropdown (kein 3-Punkte-Icon):
- Avatar als Trigger im AppHeader (User-Wunsch)
- Custom-Modal beide Plattformen, Card-Style
- SOS prominent oben (nur Wort 'SOS' rot, Tagline 'wir sind für dich da' klein darunter)
- Profile/Settings/Games/Debug(__DEV__)/Logout
- Logout neutral (nicht rot — Recovery-tonal)
- AppHeader: neue showBack + title Props für Sub-Routes
Routes (Stub bis Phase C):
- app/profile/[userId].tsx — anonym (nur public-Stats)
- app/settings.tsx — Coming-Soon-Skeleton
- app/games.tsx — Standalone Games-Page mit GameCard-Grid
- app/debug.tsx — __DEV__-only
Game-Picker (Migration aus Nuxt):
- components/games/{GameCard, StarRating, GameRatingStars}
- 2x2 Grid, 56pt SVG-Icons (inline aus components/urge/gameSvgs.ts)
- Live-Backend /api/games/ratings (silent-fail)
- Re-use UrgeGames.tsx ohne TTS/Cooldown-Loop
UI-Pattern-Fixes (alle aus screenshot-User-Feedback 2026-05-07):
- Snake-Bug (food-pellet React-18-StrictMode-Reducer-double-call) gefixt
- Snake-Buttons platform-native (iOS-blue / Android-ripple)
- Tetris-Margins (16px paddingHorizontal)
- PostCard-Buttons Apple-44pt-Hit-Area (Image-Select, Image-Remove,
Cancel, Share-Pill — via hitSlop)
- ProfileHeader Demographics-Hint: alignSelf:'stretch' Pattern
- ApprovedDomainsList Collapse: Title flex:1 + Chevron rechts
- ProtectionDetailsSheet FAQ-Items: alignSelf:'stretch' defensive
- AppHeader Back-Button: neue showBack-Prop + chevron-back
Memory + Plan-Docs:
- 17 Memory-Files dokumentieren System-Wissen + Patterns
- ops/{CUTOVER, UI_MIGRATION, PROFILE_PAGE, WEBHOOK, GAMES_1V1,
RELEASE_READINESS, TESTING_STATE, MAESTRO_HOSTING}_*.md
Backend bleibt unverändert (Tier-LLM + Nickname + sort:latency
sind seit gestern deployed).
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
// SOS+TTS Latenz-Benchmark.
|
|
//
|
|
// Eine BenchSession pro sendToLyra-Call. Aggregiert Timing-Marker aus
|
|
// sosStream + sosTtsQueue und druckt am Ende eine Tabelle ins Dev-Console.
|
|
//
|
|
// Marker-Reihenfolge im typischen Flow:
|
|
// t0 request-fired (sendToLyra start)
|
|
// t1 session-post-done (POST /sos-session resolved → sessionId da)
|
|
// t2 sse-first-chunk ("Lyra denkt fertig" — erstes Token)
|
|
// t3 sse-done (full text fertig)
|
|
// t4 tts-fetch-start (POST an /api/coach/speak-* fired)
|
|
// t5 tts-fetch-headers (response headers da, body kommt noch)
|
|
// t6 tts-body-done (kompletter Audio-Body geladen — DAS ist der Bottleneck)
|
|
// t7 tts-file-written (base64 → File geschrieben)
|
|
// t8 audio-loaded (Audio.Sound.createAsync resolved)
|
|
// t9 first-audio (erstes onPlaybackStatusUpdate mit isPlaying)
|
|
//
|
|
// Bottleneck-Diagnose:
|
|
// - (t6 - t5) groß → Body-Download dominiert. Cartesia's TTFB-Vorteil
|
|
// verpufft hier weil wir auf alles warten statt zu streamen.
|
|
// - (t9 - t8) groß → expo-av lädt langsam (file-codec-detect etc.)
|
|
|
|
export type BenchMarker =
|
|
| 'session-post-start'
|
|
| 'session-post-done'
|
|
| 'sse-first-chunk'
|
|
| 'sse-done'
|
|
| 'tts-fetch-start'
|
|
| 'tts-fetch-headers'
|
|
| 'tts-body-done'
|
|
| 'tts-file-written'
|
|
| 'audio-loaded'
|
|
| 'first-audio';
|
|
|
|
export type BenchOnMetric = (marker: BenchMarker, meta?: Record<string, unknown>) => void;
|
|
|
|
type MarkerEntry = {
|
|
marker: BenchMarker;
|
|
/** ms relativ zu t0 */
|
|
tRel: number;
|
|
meta?: Record<string, unknown>;
|
|
};
|
|
|
|
export class BenchSession {
|
|
readonly t0: number;
|
|
readonly provider: string;
|
|
readonly llm: string;
|
|
readonly label: string;
|
|
private entries: MarkerEntry[] = [];
|
|
private printed = false;
|
|
|
|
constructor(opts: { provider: string; llm?: string; label?: string }) {
|
|
this.t0 = Date.now();
|
|
this.provider = opts.provider;
|
|
this.llm = opts.llm ?? 'unknown';
|
|
this.label = opts.label ?? 'sos-turn';
|
|
}
|
|
|
|
/** Bound version — kann direkt als onMetric weitergegeben werden. */
|
|
readonly mark: BenchOnMetric = (marker, meta) => {
|
|
if (this.printed) return;
|
|
this.entries.push({ marker, tRel: Date.now() - this.t0, meta });
|
|
};
|
|
|
|
/** Druckt eine kompakte Tabelle. Idempotent (nur 1x pro Session). */
|
|
print(extraNote?: string): void {
|
|
if (this.printed) return;
|
|
this.printed = true;
|
|
|
|
const get = (m: BenchMarker) => this.entries.find((e) => e.marker === m)?.tRel;
|
|
const fmt = (v: number | undefined) => (v == null ? '—' : `${v}ms`);
|
|
const diff = (a: BenchMarker, b: BenchMarker) => {
|
|
const va = get(a), vb = get(b);
|
|
return va != null && vb != null ? `${vb - va}ms` : '—';
|
|
};
|
|
|
|
const stages = {
|
|
tts: this.provider,
|
|
llm: this.llm,
|
|
label: this.label,
|
|
'req→session': fmt(get('session-post-done')),
|
|
'lyra-ttfb': fmt(get('sse-first-chunk')),
|
|
'lyra-done': fmt(get('sse-done')),
|
|
'tts-fired': fmt(get('tts-fetch-start')),
|
|
'tts-ttfb (rel)': diff('tts-fetch-start', 'tts-fetch-headers'),
|
|
'tts-body (rel)': diff('tts-fetch-headers', 'tts-body-done'),
|
|
'tts-file (rel)': diff('tts-body-done', 'tts-file-written'),
|
|
'audio-load (rel)': diff('tts-file-written', 'audio-loaded'),
|
|
'first-audio': fmt(get('first-audio')),
|
|
'TOTAL → speak': fmt(get('first-audio')),
|
|
};
|
|
|
|
// Eine kompakte Zeile als console.log (für Logbox-Lesbarkeit) +
|
|
// console.table mit allen Markern (für strukturierte Inspektion).
|
|
// eslint-disable-next-line no-console
|
|
console.log(
|
|
`[bench] LLM=${this.llm} TTS=${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`,
|
|
stages,
|
|
);
|
|
// eslint-disable-next-line no-console
|
|
console.table(this.entries.map((e) => ({ marker: e.marker, tRel: e.tRel })));
|
|
}
|
|
|
|
/** Snapshot für UI-Overlays (Debug-Drawer etc.). */
|
|
snapshot(): { provider: string; llm: string; label: string; entries: MarkerEntry[] } {
|
|
return { provider: this.provider, llm: this.llm, label: this.label, entries: [...this.entries] };
|
|
}
|
|
}
|