diff --git a/README.md b/README.md index 8b435b3..be3e770 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank. - Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern - Vier Bereiche: Dashboard, Tracking, Optionen, Archiv - Speicherung aller Tage als Markdown in `storage/users//days/YYYY-MM-DD.txt` +- KI-Wochen- und Monatszusammenfassungen im Archiv mit verschlüsselter dateibasierter Ablage - Pro Nutzer eigene Einstellungen für die Bewertungslogik - Admin kann weitere Accounts direkt in der Weboberfläche anlegen - Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs @@ -30,9 +31,24 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank. ## Hinweise - Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien. +- Tagesdateien und KI-Zusammenfassungen werden serverseitig verschlüsselt gespeichert und im Backup wieder als lesbare TXT-Dateien exportiert. - Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt. - Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar. +## KI-Zusammenfassungen + +- Für KI-Zusammenfassungen im Archiv wird ein OpenAI-Modell aus der Mini-Klasse verwendet. +- Der API-Key kommt aus der Server-Umgebung, das Modell und der Timeout können zusätzlich zentral durch einen Admin in den Optionen angepasst werden. +- Wochenzusammenfassungen werden als `storage/users//summaries/weekly/YYYY-KW-XX.txt` gespeichert. +- Monatszusammenfassungen werden als `storage/users//summaries/monthly/YYYY-MM.txt` gespeichert. +- Der Backup-Export nimmt diese Dateien automatisch mit und legt sie im ZIP unter `summaries/weekly/` und `summaries/monthly/` ab. + +### Benötigte Umgebungsvariablen + +- `OPENAI_API_KEY` (erforderlich) +- `OPENAI_MODEL` (optional, Standard: `gpt-4o-mini`) +- `OPENAI_TIMEOUT` (optional, Standard: `25`) + ## Lizenz - Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`. diff --git a/assets/css/app.css b/assets/css/app.css index 7425ddd..d2a1e82 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1150,6 +1150,59 @@ input[type="range"] { gap: 0.75rem; } +.archive-summary-section { + display: grid; + gap: 0.9rem; + margin-bottom: 1.2rem; +} + +.archive-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 0.85rem; +} + +.archive-summary-card { + display: grid; + gap: 0.7rem; + padding: 1rem 1.05rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.archive-summary-card__head { + display: flex; + justify-content: space-between; + gap: 0.8rem; + align-items: flex-start; +} + +.archive-summary-card__head strong { + display: block; + margin-top: 0.35rem; +} + +.summary-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.15rem; + min-height: 1.55rem; + padding: 0.15rem 0.5rem; + border-radius: 999px; + background: rgba(139, 228, 255, 0.16); + border: 1px solid rgba(139, 228, 255, 0.24); + color: var(--text); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.06em; +} + +.chart-chip--muted { + opacity: 0.82; +} + .archive-item { display: flex; justify-content: space-between; @@ -1185,6 +1238,15 @@ input[type="range"] { flex-wrap: wrap; } +.archive-item__actions--stack { + justify-content: flex-start; + align-items: stretch; +} + +.archive-item__actions--stack form { + margin: 0; +} + .archive-action { min-height: 2.4rem; padding-inline: 0.85rem; @@ -1242,6 +1304,10 @@ input[type="range"] { background: rgba(255, 255, 255, 0.08); } +.note-box--summary p { + white-space: pre-wrap; +} + .note-box h4 { margin: 0 0 0.55rem; } @@ -1536,6 +1602,10 @@ input[type="range"] { align-items: flex-start; } + .archive-summary-grid { + grid-template-columns: 1fr; + } + .calendar-detail { flex-direction: column; align-items: flex-start; diff --git a/src/App.php b/src/App.php index 4bd5ce4..3e2cce3 100644 --- a/src/App.php +++ b/src/App.php @@ -12,6 +12,9 @@ final class App private Auth $auth; private NotificationRepository $notifications; private WebPushService $webPush; + private SummaryRepository $summaries; + private AiConfigRepository $aiConfig; + private OpenAiSummaryService $openAi; public function __construct() { @@ -23,6 +26,9 @@ final class App $this->auth = new Auth($this->users); $this->notifications = new NotificationRepository(); $this->webPush = new WebPushService($this->notifications); + $this->summaries = new SummaryRepository(); + $this->aiConfig = new AiConfigRepository(); + $this->openAi = new OpenAiSummaryService($this->aiConfig); } public function run(): void @@ -89,7 +95,7 @@ final class App return; case '/archive': - $this->showArchive(); + $method === 'POST' ? $this->handleArchive() : $this->showArchive(); return; case '/options': @@ -340,8 +346,12 @@ final class App $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null; + $selectedSummaryKind = isset($_GET['summary_kind']) ? (string) $_GET['summary_kind'] : null; + $selectedSummaryKey = isset($_GET['summary_key']) ? (string) $_GET['summary_key'] : null; $entries = $this->entries->all($user['username']); $archive = array_reverse($this->evaluateEntriesWithContext($entries, $settings)); + $weeklySummaries = $this->summaries->weekly($user['username']); + $monthlySummaries = $this->summaries->monthly($user['username']); $selectedEntry = null; if ($selectedDate !== null) { @@ -353,16 +363,78 @@ final class App } } + $selectedSummary = null; + if ($selectedSummaryKind !== null && $selectedSummaryKey !== null) { + $selectedSummary = $this->summaries->find($user['username'], $selectedSummaryKind, $selectedSummaryKey); + } + View::render('archive', [ 'pageTitle' => 'Archiv', 'page' => 'archive', 'authUser' => $user, 'entries' => $archive, 'selectedEntry' => $selectedEntry, + 'selectedSummary' => $selectedSummary, 'settings' => $settings, + 'weeklyArchive' => $this->buildWeeklyArchiveCards($archive, $weeklySummaries), + 'monthlyArchive' => $this->buildMonthlyArchiveCards($archive, $weeklySummaries, $monthlySummaries), + 'aiAvailable' => $this->openAi->isAvailable(), ]); } + private function handleArchive(): void + { + $this->enforceCsrf(); + + $user = $this->requireUser(); + $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); + $entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings); + $form = (string) ($_POST['form_name'] ?? ''); + + try { + if ($form === 'generate_weekly_summary') { + $weekKey = trim((string) ($_POST['week_key'] ?? '')); + $context = $this->buildWeeklySummaryContext($weekKey, $entries); + $text = $this->openAi->generateWeekly($context['prompt']); + + $this->summaries->save($user['username'], 'weekly', $weekKey, [ + 'title' => 'Wochenzusammenfassung ' . iso_week_label($weekKey), + 'created_at' => date(DATE_ATOM), + 'date_from' => $context['date_from'], + 'date_to' => $context['date_to'], + 'text' => $text, + ]); + + flash('success', 'Die KI-Wochenzusammenfassung wurde erstellt.'); + redirect('/archive?summary_kind=weekly&summary_key=' . rawurlencode($weekKey)); + } + + if ($form === 'generate_monthly_summary') { + $monthKey = trim((string) ($_POST['month_key'] ?? '')); + $weeklySummaries = $this->summaries->weekly($user['username']); + $context = $this->buildMonthlySummaryContext($monthKey, $entries, $weeklySummaries); + $text = $this->openAi->generateMonthly($context['prompt']); + preg_match('/^(\d{4})-(\d{2})$/', $monthKey, $monthMatches); + + $this->summaries->save($user['username'], 'monthly', $monthKey, [ + 'title' => 'Monatszusammenfassung ' . (string) ($monthMatches[2] ?? '') . ' / ' . (string) ($monthMatches[1] ?? ''), + 'created_at' => date(DATE_ATOM), + 'date_from' => $context['date_from'], + 'date_to' => $context['date_to'], + 'text' => $text, + ]); + + flash('success', 'Die KI-Monatszusammenfassung wurde erstellt.'); + redirect('/archive?summary_kind=monthly&summary_key=' . rawurlencode($monthKey)); + } + } catch (RuntimeException $exception) { + flash('error', $exception->getMessage()); + redirect('/archive'); + } + + redirect('/archive'); + } + private function showOptions(): void { $user = $this->requireUser(); @@ -402,6 +474,8 @@ final class App 'pushPublicKey' => $pushPublicKey, 'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']), 'backupAvailable' => class_exists('ZipArchive'), + 'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null, + 'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null, 'users' => $user['is_admin'] ? $this->users->all() : [], 'maxScore' => $this->scoring->evaluate([ 'mood' => 10, @@ -440,6 +514,12 @@ final class App redirect('/options'); } + if ($form === 'ai_config' && ($user['is_admin'] ?? false)) { + $this->aiConfig->save($_POST['ai'] ?? []); + flash('success', 'Die zentrale KI-Konfiguration wurde aktualisiert.'); + redirect('/options'); + } + if ($form === 'export_backup') { $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $this->downloadUserBackup($user, $settings); @@ -450,7 +530,12 @@ final class App try { $imported = $this->importUserBackup($user, $settings); - flash('success', $imported . ' Tag' . ($imported === 1 ? '' : 'e') . ' aus dem Backup importiert.'); + flash( + 'success', + $imported === 1 + ? '1 Archivobjekt wurde aus dem Backup verarbeitet.' + : $imported . ' Archivobjekte wurden aus dem Backup verarbeitet.' + ); } catch (RuntimeException $exception) { flash('error', $exception->getMessage()); } @@ -609,6 +694,17 @@ final class App $zip->addFromString($date . '.txt', $markdown); } + foreach ($this->summaries->exportBackupFiles((string) ($user['username'] ?? '')) as $summaryFile) { + $path = trim((string) ($summaryFile['path'] ?? '')); + $content = (string) ($summaryFile['content'] ?? ''); + + if ($path === '' || $content === '') { + continue; + } + + $zip->addFromString($path, $content); + } + $zip->close(); $fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip'; @@ -632,6 +728,7 @@ final class App } $importedEntries = []; + $importedSummaryCount = 0; foreach ($files as $file) { $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); @@ -653,9 +750,11 @@ final class App $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if ($extension === 'zip') { - foreach ($this->entriesFromZip($tmpName) as $date => $entry) { + $zipContent = $this->entriesFromZip($user['username'], $tmpName); + foreach ($zipContent['entries'] as $date => $entry) { $importedEntries[$date] = $entry; } + $importedSummaryCount += (int) ($zipContent['summaries'] ?? 0); continue; } @@ -663,8 +762,13 @@ final class App throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.'); } - $date = $this->dateFromBackupFileName($name); $content = (string) file_get_contents($tmpName); + if ($this->summaries->importBackupFile((string) ($user['username'] ?? ''), $name, $content)) { + $importedSummaryCount++; + continue; + } + + $date = $this->dateFromBackupFileName($name); $entry = $this->entries->parseMarkdown($content, $date); if ($entry === null) { @@ -674,8 +778,8 @@ final class App $importedEntries[$date] = $entry; } - if ($importedEntries === []) { - throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien gefunden.'); + if ($importedEntries === [] && $importedSummaryCount === 0) { + throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien oder KI-Zusammenfassungen gefunden.'); } $existingEntries = $this->entries->all((string) ($user['username'] ?? '')); @@ -693,12 +797,14 @@ final class App $entryMap[$date] = $entry; } - $this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap)); + if ($importedEntries !== []) { + $this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap)); + } - return count($importedEntries); + return count($importedEntries) + $importedSummaryCount; } - private function entriesFromZip(string $path): array + private function entriesFromZip(string $username, string $path): array { if (!class_exists('ZipArchive')) { throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.'); @@ -712,6 +818,7 @@ final class App } $entries = []; + $summaryCount = 0; for ($index = 0; $index < $zip->numFiles; $index++) { $name = (string) $zip->getNameIndex($index); @@ -720,17 +827,23 @@ final class App } $baseName = basename($name); - if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) { - continue; - } - - $date = $this->dateFromBackupFileName($baseName); $content = $zip->getFromIndex($index); if (!is_string($content)) { continue; } + if ($this->summaries->importBackupFile($username, $name, $content)) { + $summaryCount++; + continue; + } + + if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) { + continue; + } + + $date = $this->dateFromBackupFileName($baseName); + $entry = $this->entries->parseMarkdown($content, $date); if ($entry !== null) { $entries[$date] = $entry; @@ -739,7 +852,10 @@ final class App $zip->close(); - return $entries; + return [ + 'entries' => $entries, + 'summaries' => $summaryCount, + ]; } private function dateFromBackupFileName(string $fileName): string @@ -961,6 +1077,373 @@ final class App return $evaluated; } + private function buildWeeklyArchiveCards(array $entries, array $weeklySummaries): array + { + $groups = []; + + foreach ($entries as $entry) { + $key = iso_week_key((string) ($entry['date'] ?? '')); + if (!isset($groups[$key])) { + $groups[$key] = []; + } + + $groups[$key][] = $entry; + } + + $summaryMap = []; + foreach ($weeklySummaries as $summary) { + $key = (string) ($summary['summary_key'] ?? ''); + if ($key !== '') { + $summaryMap[$key] = $summary; + $groups[$key] = $groups[$key] ?? []; + } + } + + $cards = []; + foreach ($groups as $key => $weekEntries) { + $range = $this->weekRangeFromKey($key); + $summary = $summaryMap[$key] ?? null; + $noteEntries = array_values(array_filter( + $weekEntries, + static fn (array $entry): bool => trim((string) ($entry['note'] ?? '')) !== '' + )); + + $cards[] = [ + 'summary_key' => $key, + 'label' => iso_week_label($key), + 'date_from' => $summary['date_from'] ?? $range['date_from'], + 'date_to' => $summary['date_to'] ?? $range['date_to'], + 'tracked_days' => count($weekEntries), + 'note_entries_count' => count($noteEntries), + 'can_generate' => count($noteEntries) >= 3, + 'summary' => $summary, + 'has_summary' => $summary !== null, + ]; + } + + usort($cards, static fn (array $left, array $right): int => strcmp((string) $right['date_to'], (string) $left['date_to'])); + + return $cards; + } + + private function buildMonthlyArchiveCards(array $entries, array $weeklySummaries, array $monthlySummaries): array + { + $monthKeys = []; + + foreach ($entries as $entry) { + $monthKeys[month_key((string) ($entry['date'] ?? ''))] = true; + } + + foreach ($monthlySummaries as $summary) { + $monthKeys[(string) ($summary['summary_key'] ?? '')] = true; + } + + foreach ($weeklySummaries as $summary) { + foreach ($this->monthKeysForRange((string) ($summary['date_from'] ?? ''), (string) ($summary['date_to'] ?? '')) as $monthKey) { + $monthKeys[$monthKey] = true; + } + } + + $monthlySummaryMap = []; + foreach ($monthlySummaries as $summary) { + $key = (string) ($summary['summary_key'] ?? ''); + if ($key !== '') { + $monthlySummaryMap[$key] = $summary; + } + } + + $cards = []; + foreach (array_keys($monthKeys) as $monthKey) { + $range = $this->monthRangeFromKey($monthKey); + $monthEntries = array_values(array_filter( + $entries, + static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $monthKey + )); + $monthWeeklySummaries = array_values(array_filter( + $weeklySummaries, + fn (array $summary): bool => $this->summaryOverlapsMonth($summary, $monthKey) + )); + + usort($monthWeeklySummaries, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? ''))); + + $cards[] = [ + 'summary_key' => $monthKey, + 'label' => month_label($monthKey), + 'date_from' => $range['date_from'], + 'date_to' => $range['date_to'], + 'tracked_days' => count($monthEntries), + 'weekly_summary_count' => count($monthWeeklySummaries), + 'can_generate' => count($monthWeeklySummaries) >= 2, + 'summary' => $monthlySummaryMap[$monthKey] ?? null, + 'has_summary' => isset($monthlySummaryMap[$monthKey]), + ]; + } + + usort($cards, static fn (array $left, array $right): int => strcmp((string) $right['summary_key'], (string) $left['summary_key'])); + + return $cards; + } + + private function buildWeeklySummaryContext(string $weekKey, array $entries): array + { + $range = $this->weekRangeFromKey($weekKey); + $weekEntries = array_values(array_filter( + $entries, + static fn (array $entry): bool => iso_week_key((string) ($entry['date'] ?? '')) === $weekKey + )); + + if ($weekEntries === []) { + throw new RuntimeException('Für diese Kalenderwoche gibt es noch keine getrackten Tage.'); + } + + $noteEntries = array_values(array_filter( + $weekEntries, + static fn (array $entry): bool => trim((string) ($entry['note'] ?? '')) !== '' + )); + + if (count($noteEntries) < 3) { + throw new RuntimeException('Für eine KI-Wochenzusammenfassung sind mindestens 3 Texteinträge nötig.'); + } + + usort($weekEntries, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); + usort($noteEntries, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); + + $bestDay = $this->bestEntry($weekEntries); + $worstDay = $this->worstEntry($weekEntries); + $alcoholDays = count(array_filter($weekEntries, static fn (array $entry): bool => !empty($entry['alcohol']))); + $sportDays = count(array_filter($weekEntries, static fn (array $entry): bool => (int) ($entry['sport_minutes'] ?? 0) > 0)); + + return [ + 'date_from' => $range['date_from'], + 'date_to' => $range['date_to'], + 'prompt' => [ + 'week_label' => iso_week_label($weekKey) . ' (' . $range['date_from'] . ' bis ' . $range['date_to'] . ')', + 'entry_count' => (string) count($noteEntries), + 'tracked_days' => (string) count($weekEntries), + 'avg_mood' => format_points($this->average($weekEntries, 'mood')), + 'avg_stress' => format_points($this->average($weekEntries, 'stress')), + 'avg_energy' => format_points($this->average($weekEntries, 'energy')), + 'avg_sleep' => format_points($this->average($weekEntries, 'sleep_hours')) . ' h', + 'walk_days' => (string) count(array_filter($weekEntries, static fn (array $entry): bool => walk_chart_value($entry) > 0)), + 'sport_days' => (string) $sportDays, + 'alcohol_days' => (string) $alcoholDays, + 'best_day' => $bestDay !== null ? $this->summaryDayLabel($bestDay) : 'nicht bestimmbar', + 'worst_day' => $worstDay !== null ? $this->summaryDayLabel($worstDay) : 'nicht bestimmbar', + 'daily_entries' => $this->renderWeeklyDailyEntries($noteEntries), + ], + ]; + } + + private function buildMonthlySummaryContext(string $monthKey, array $entries, array $weeklySummaries): array + { + $range = $this->monthRangeFromKey($monthKey); + $monthEntries = array_values(array_filter( + $entries, + static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $monthKey + )); + $monthWeeklySummaries = array_values(array_filter( + $weeklySummaries, + fn (array $summary): bool => $this->summaryOverlapsMonth($summary, $monthKey) + )); + + usort($monthWeeklySummaries, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? ''))); + + if (count($monthWeeklySummaries) < 2) { + throw new RuntimeException('Für eine KI-Monatszusammenfassung sind mindestens 2 KI-Wochenzusammenfassungen nötig.'); + } + + return [ + 'date_from' => $range['date_from'], + 'date_to' => $range['date_to'], + 'prompt' => [ + 'month_label' => month_label($monthKey) . ' (' . $range['date_from'] . ' bis ' . $range['date_to'] . ')', + 'weekly_summary_count' => (string) count($monthWeeklySummaries), + 'avg_mood_month' => format_points($this->average($monthEntries, 'mood')), + 'avg_stress_month' => format_points($this->average($monthEntries, 'stress')), + 'avg_energy_month' => format_points($this->average($monthEntries, 'energy')), + 'avg_sleep_month' => format_points($this->average($monthEntries, 'sleep_hours')) . ' h', + 'weekly_summaries' => $this->renderMonthlyWeeklySummaries($monthWeeklySummaries, $entries), + ], + ]; + } + + private function renderWeeklyDailyEntries(array $entries): string + { + $chunks = []; + + foreach ($entries as $entry) { + $details = [ + 'Stimmung ' . (string) ($entry['mood'] ?? 0) . '/10', + 'Energie ' . (string) ($entry['energy'] ?? 0) . '/10', + 'Stress ' . (string) ($entry['stress'] ?? 0) . '/10', + 'Schlaf ' . format_points((float) ($entry['sleep_hours'] ?? 0)) . ' h', + 'Schlafgefühl ' . (string) ($entry['sleep_feeling'] ?? 0) . '/5', + 'Sport ' . (string) ((int) ($entry['sport_minutes'] ?? 0)) . ' min', + 'Spaziergang ' . format_walk_value($entry), + 'Alkohol ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'), + 'Urteil ' . (string) ($entry['evaluation']['label'] ?? ''), + ]; + + if (array_key_exists('pain', $entry) && !empty($entry['pain_enabled'])) { + $details[] = 'Schmerzen ' . (string) ($entry['pain'] ?? 0) . '/10'; + } + + $chunks[] = implode("\n", [ + format_display_date((string) ($entry['date'] ?? ''), false) . ' (' . (string) ($entry['date'] ?? '') . ')', + implode(' · ', $details), + 'Notiz: ' . trim((string) ($entry['note'] ?? '')), + ]); + } + + return implode("\n\n", $chunks); + } + + private function renderMonthlyWeeklySummaries(array $weeklySummaries, array $entries): string + { + $blocks = []; + + foreach ($weeklySummaries as $summary) { + $weekKey = (string) ($summary['summary_key'] ?? ''); + $weekEntries = array_values(array_filter( + $entries, + static fn (array $entry): bool => iso_week_key((string) ($entry['date'] ?? '')) === $weekKey + )); + + $blocks[] = implode("\n", [ + iso_week_label($weekKey) . ' (' . (string) ($summary['date_from'] ?? '') . ' bis ' . (string) ($summary['date_to'] ?? '') . ')', + 'Wochenkennzahlen: Stimmung ' . format_points($this->average($weekEntries, 'mood')) + . ' · Stress ' . format_points($this->average($weekEntries, 'stress')) + . ' · Energie ' . format_points($this->average($weekEntries, 'energy')) + . ' · Schlaf ' . format_points($this->average($weekEntries, 'sleep_hours')) . ' h', + 'KI-Wochenrückblick:', + trim((string) ($summary['text'] ?? '')), + ]); + } + + return implode("\n\n", $blocks); + } + + private function average(array $entries, string $field): float + { + if ($entries === []) { + return 0.0; + } + + $sum = 0.0; + foreach ($entries as $entry) { + $sum += (float) ($entry[$field] ?? 0); + } + + return round($sum / count($entries), 1); + } + + private function bestEntry(array $entries): ?array + { + if ($entries === []) { + return null; + } + + usort($entries, static fn (array $left, array $right): int => ((float) ($right['evaluation']['total'] ?? 0)) <=> ((float) ($left['evaluation']['total'] ?? 0))); + + return $entries[0] ?? null; + } + + private function worstEntry(array $entries): ?array + { + if ($entries === []) { + return null; + } + + usort($entries, static fn (array $left, array $right): int => ((float) ($left['evaluation']['total'] ?? 0)) <=> ((float) ($right['evaluation']['total'] ?? 0))); + + return $entries[0] ?? null; + } + + private function summaryDayLabel(array $entry): string + { + return format_display_date((string) ($entry['date'] ?? ''), false) + . ' · ' + . format_points((float) ($entry['evaluation']['total'] ?? 0)) + . ' Punkte' + . ' · ' + . (string) ($entry['evaluation']['label'] ?? ''); + } + + private function weekRangeFromKey(string $weekKey): array + { + if (preg_match('/^(\d{4})-KW-(\d{2})$/', $weekKey, $matches) !== 1) { + throw new RuntimeException('Die Kalenderwoche ist ungültig.'); + } + + $year = (int) ($matches[1] ?? 0); + $week = (int) ($matches[2] ?? 0); + if ($week < 1 || $week > 53) { + throw new RuntimeException('Die Kalenderwoche ist ungültig.'); + } + + $start = (new DateTimeImmutable('now'))->setISODate($year, $week, 1); + $end = $start->modify('+6 days'); + + return [ + 'date_from' => $start->format('Y-m-d'), + 'date_to' => $end->format('Y-m-d'), + ]; + } + + private function monthRangeFromKey(string $monthKey): array + { + if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $monthKey) !== 1) { + throw new RuntimeException('Der Monat ist ungültig.'); + } + + $current = DateTimeImmutable::createFromFormat('Y-m-d', $monthKey . '-01'); + + if ($current === false) { + throw new RuntimeException('Der Monat ist ungültig.'); + } + + return [ + 'date_from' => $current->format('Y-m-01'), + 'date_to' => $current->modify('last day of this month')->format('Y-m-d'), + ]; + } + + private function monthKeysForRange(string $dateFrom, string $dateTo): array + { + if (!$this->isValidDate($dateFrom) || !$this->isValidDate($dateTo)) { + return []; + } + + $keys = []; + $current = DateTimeImmutable::createFromFormat('Y-m-d', $dateFrom); + $end = DateTimeImmutable::createFromFormat('Y-m-d', $dateTo); + + if ($current === false || $end === false) { + return []; + } + + $current = $current->modify('first day of this month'); + $end = $end->modify('first day of this month'); + + while ($current <= $end) { + $keys[] = $current->format('Y-m'); + $current = $current->modify('+1 month'); + } + + return $keys; + } + + private function summaryOverlapsMonth(array $summary, string $monthKey): bool + { + $range = $this->monthRangeFromKey($monthKey); + $summaryFrom = (string) ($summary['date_from'] ?? ''); + $summaryTo = (string) ($summary['date_to'] ?? ''); + + return $summaryFrom !== '' && $summaryTo !== '' + && $summaryFrom <= $range['date_to'] + && $summaryTo >= $range['date_from']; + } + private function sendSecurityHeaders(): void { header('Referrer-Policy: strict-origin-when-cross-origin'); diff --git a/src/Domain/AiConfigRepository.php b/src/Domain/AiConfigRepository.php new file mode 100644 index 0000000..2766ef3 --- /dev/null +++ b/src/Domain/AiConfigRepository.php @@ -0,0 +1,68 @@ +path = storage_path('system/ai.json'); + } + + public function get(): array + { + $saved = decode_json_file($this->path, []); + $config = array_replace($this->defaults(), is_array($saved) ? $saved : []); + + $config['model'] = trim((string) ($config['model'] ?? $this->defaults()['model'])); + if ($config['model'] === '') { + $config['model'] = $this->defaults()['model']; + } + + $config['timeout'] = max(5, min(120, (int) ($config['timeout'] ?? $this->defaults()['timeout']))); + + return $config; + } + + public function save(array $input): array + { + $config = [ + 'model' => trim((string) ($input['model'] ?? $this->defaults()['model'])), + 'timeout' => max(5, min(120, (int) ($input['timeout'] ?? $this->defaults()['timeout']))), + ]; + + if ($config['model'] === '') { + $config['model'] = $this->defaults()['model']; + } + + $directory = dirname($this->path); + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + $bytes = file_put_contents( + $this->path, + json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + LOCK_EX + ); + + if ($bytes === false) { + throw new RuntimeException('Die KI-Konfiguration konnte nicht gespeichert werden.'); + } + + return $config; + } + + private function defaults(): array + { + $model = trim((string) ($_ENV['OPENAI_MODEL'] ?? getenv('OPENAI_MODEL') ?: 'gpt-4o-mini')); + $timeout = (int) ($_ENV['OPENAI_TIMEOUT'] ?? getenv('OPENAI_TIMEOUT') ?: 25); + + return [ + 'model' => $model !== '' ? $model : 'gpt-4o-mini', + 'timeout' => max(5, min(120, $timeout > 0 ? $timeout : 25)), + ]; + } +} diff --git a/src/Domain/SummaryRepository.php b/src/Domain/SummaryRepository.php new file mode 100644 index 0000000..3d994a6 --- /dev/null +++ b/src/Domain/SummaryRepository.php @@ -0,0 +1,320 @@ +crypto = new EntryCrypto(); + } + + public function all(string $username): array + { + $items = array_merge( + $this->readKind($username, 'weekly'), + $this->readKind($username, 'monthly') + ); + + usort($items, static function (array $left, array $right): int { + $leftDate = (string) ($left['date_to'] ?? ''); + $rightDate = (string) ($right['date_to'] ?? ''); + $byDate = strcmp($rightDate, $leftDate); + + if ($byDate !== 0) { + return $byDate; + } + + return strcmp((string) ($right['summary_key'] ?? ''), (string) ($left['summary_key'] ?? '')); + }); + + return $items; + } + + public function weekly(string $username): array + { + return $this->readKind($username, 'weekly'); + } + + public function monthly(string $username): array + { + return $this->readKind($username, 'monthly'); + } + + public function find(string $username, string $kind, string $key): ?array + { + $path = $this->pathFor($username, $kind, $key); + if (!is_file($path)) { + return null; + } + + $content = (string) file_get_contents($path); + $plaintext = $this->crypto->decrypt($content); + + if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) { + file_put_contents($path, $this->crypto->encrypt($plaintext), LOCK_EX); + } + + return $this->parse($plaintext, $kind, $key); + } + + public function save(string $username, string $kind, string $key, array $summary): void + { + $normalized = $this->normalizeSummary($kind, $key, $summary); + $path = $this->pathFor($username, $kind, $key); + $directory = dirname($path); + + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + file_put_contents($path, $this->crypto->encrypt($this->toText($normalized)), LOCK_EX); + } + + public function exportBackupFiles(string $username): array + { + $exports = []; + + foreach (['weekly', 'monthly'] as $kind) { + foreach ($this->readKind($username, $kind) as $summary) { + $exports[] = [ + 'path' => 'summaries/' . $kind . '/' . (string) $summary['summary_key'] . '.txt', + 'content' => $this->toText($summary), + ]; + } + } + + return $exports; + } + + public function importBackupFile(string $username, string $fileName, string $content): bool + { + $detected = $this->detectBackupFile($fileName); + if ($detected === null) { + return false; + } + + $summary = $this->parse($content, $detected['kind'], $detected['key']); + if ($summary === null) { + throw new RuntimeException('Eine KI-Zusammenfassung aus dem Backup konnte nicht gelesen werden.'); + } + + $this->save($username, $detected['kind'], $detected['key'], $summary); + + return true; + } + + private function readKind(string $username, string $kind): array + { + $directory = $this->directoryFor($username, $kind); + if (!is_dir($directory)) { + return []; + } + + $files = glob($directory . '/*.txt') ?: []; + rsort($files, SORT_STRING); + + $summaries = []; + foreach ($files as $file) { + $key = basename($file, '.txt'); + $content = (string) file_get_contents($file); + $plaintext = $this->crypto->decrypt($content); + + if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) { + file_put_contents($file, $this->crypto->encrypt($plaintext), LOCK_EX); + } + + $summary = $this->parse($plaintext, $kind, $key); + if ($summary !== null) { + $summaries[] = $summary; + } + } + + return $summaries; + } + + private function parse(string $content, string $kind, string $key): ?array + { + $plaintext = $this->crypto->decrypt($content); + $kind = $this->normalizeKind($kind); + if ($kind === null || !$this->isValidKey($kind, $key)) { + return null; + } + + $title = $this->extract('/^Titel:\s*(.+)$/mu', $plaintext); + $type = $this->extract('/^Typ:\s*(.+)$/mu', $plaintext); + $createdAt = $this->extract('/^Erstellt am:\s*(.+)$/mu', $plaintext); + + if (!preg_match('/^Zeitraum:\s*(\d{4}-\d{2}-\d{2})\s+bis\s+(\d{4}-\d{2}-\d{2})$/mu', $plaintext, $rangeMatch)) { + return null; + } + + if (!preg_match('/^Zeitraum:\s*.+$\R\R([\s\S]*)\z/mu', $plaintext, $textMatch)) { + return null; + } + + if ($title === null || $type === null || $createdAt === null) { + return null; + } + + $expectedType = $kind === 'weekly' ? 'ki-wochenauswertung' : 'ki-monatsauswertung'; + if (trim($type) !== $expectedType) { + return null; + } + + $summary = [ + 'summary_kind' => $kind, + 'summary_key' => $key, + 'title' => trim($title), + 'type' => $expectedType, + 'created_at' => trim($createdAt), + 'date_from' => trim((string) ($rangeMatch[1] ?? '')), + 'date_to' => trim((string) ($rangeMatch[2] ?? '')), + 'text' => trim((string) ($textMatch[1] ?? '')), + ]; + + if (!$this->isValidDate($summary['date_from']) || !$this->isValidDate($summary['date_to'])) { + return null; + } + + return $summary; + } + + private function toText(array $summary): string + { + $normalized = $this->normalizeSummary( + (string) $summary['summary_kind'], + (string) $summary['summary_key'], + $summary + ); + + return implode("\n", [ + 'Titel: ' . $normalized['title'], + 'Typ: ' . $normalized['type'], + 'Erstellt am: ' . $normalized['created_at'], + 'Zeitraum: ' . $normalized['date_from'] . ' bis ' . $normalized['date_to'], + '', + trim((string) $normalized['text']), + '', + ]); + } + + private function normalizeSummary(string $kind, string $key, array $summary): array + { + $kind = $this->normalizeKind($kind); + if ($kind === null || !$this->isValidKey($kind, $key)) { + throw new RuntimeException('Die Zusammenfassung hat einen ungültigen Schlüssel.'); + } + + $dateFrom = trim((string) ($summary['date_from'] ?? '')); + $dateTo = trim((string) ($summary['date_to'] ?? '')); + $createdAt = trim((string) ($summary['created_at'] ?? date(DATE_ATOM))); + $text = trim((string) ($summary['text'] ?? '')); + + if (!$this->isValidDate($dateFrom) || !$this->isValidDate($dateTo)) { + throw new RuntimeException('Die Zusammenfassung hat einen ungültigen Zeitraum.'); + } + + if ($text === '') { + throw new RuntimeException('Die Zusammenfassung darf nicht leer sein.'); + } + + return [ + 'summary_kind' => $kind, + 'summary_key' => $key, + 'title' => trim((string) ($summary['title'] ?? $this->defaultTitle($kind, $key))), + 'type' => $kind === 'weekly' ? 'ki-wochenauswertung' : 'ki-monatsauswertung', + 'created_at' => $createdAt, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'text' => $text, + ]; + } + + private function defaultTitle(string $kind, string $key): string + { + if ($kind === 'weekly' && preg_match('/^(\d{4})-KW-(\d{2})$/', $key, $matches)) { + return 'Wochenzusammenfassung KW ' . $matches[2] . ' / ' . $matches[1]; + } + + if ($kind === 'monthly' && preg_match('/^(\d{4})-(\d{2})$/', $key, $matches)) { + return 'Monatszusammenfassung ' . $matches[2] . ' / ' . $matches[1]; + } + + return 'KI-Zusammenfassung'; + } + + private function detectBackupFile(string $fileName): ?array + { + $normalized = str_replace('\\', '/', trim($fileName)); + $baseName = basename($normalized); + + if (preg_match('/^(\d{4}-KW-\d{2})\.txt$/', $baseName, $matches)) { + return [ + 'kind' => 'weekly', + 'key' => (string) $matches[1], + ]; + } + + if (preg_match('/^(\d{4}-\d{2})\.txt$/', $baseName, $matches)) { + return [ + 'kind' => 'monthly', + 'key' => (string) $matches[1], + ]; + } + + return null; + } + + private function directoryFor(string $username, string $kind): string + { + return storage_path('users/' . normalize_username($username) . '/summaries/' . $kind); + } + + private function pathFor(string $username, string $kind, string $key): string + { + return $this->directoryFor($username, $kind) . '/' . $key . '.txt'; + } + + private function normalizeKind(string $kind): ?string + { + $kind = trim($kind); + + return in_array($kind, ['weekly', 'monthly'], true) ? $kind : null; + } + + private function isValidKey(string $kind, string $key): bool + { + if ($kind === 'weekly' && preg_match('/^\d{4}-KW-(\d{2})$/', $key, $matches) === 1) { + $week = (int) ($matches[1] ?? 0); + + return $week >= 1 && $week <= 53; + } + + if ($kind === 'monthly' && preg_match('/^\d{4}-(\d{2})$/', $key, $matches) === 1) { + $month = (int) ($matches[1] ?? 0); + + return $month >= 1 && $month <= 12; + } + + return false; + } + + private function isValidDate(string $date): bool + { + $parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date); + + return $parsed !== false && $parsed->format('Y-m-d') === $date; + } + + private function extract(string $pattern, string $content): ?string + { + if (preg_match($pattern, $content, $matches) !== 1) { + return null; + } + + return trim((string) ($matches[1] ?? '')); + } +} diff --git a/src/Support/OpenAiSummaryService.php b/src/Support/OpenAiSummaryService.php new file mode 100644 index 0000000..1fe1b10 --- /dev/null +++ b/src/Support/OpenAiSummaryService.php @@ -0,0 +1,283 @@ +config = $config; + } + + public function configuration(): array + { + $config = $this->config->get(); + + return [ + 'model' => (string) ($config['model'] ?? 'gpt-4o-mini'), + 'timeout' => (int) ($config['timeout'] ?? 25), + 'has_api_key' => $this->apiKey() !== '', + 'available' => $this->isAvailable(), + ]; + } + + public function isAvailable(): bool + { + return function_exists('curl_init') && $this->apiKey() !== ''; + } + + public function generateWeekly(array $payload): string + { + return $this->requestSummary( + self::WEEK_SYSTEM_PROMPT, + $this->renderTemplate(self::WEEK_USER_TEMPLATE, $payload) + ); + } + + public function generateMonthly(array $payload): string + { + return $this->requestSummary( + self::MONTH_SYSTEM_PROMPT, + $this->renderTemplate(self::MONTH_USER_TEMPLATE, $payload) + ); + } + + private function requestSummary(string $systemPrompt, string $userPrompt): string + { + if (!function_exists('curl_init')) { + throw new RuntimeException('Die KI-Zusammenfassung ist auf diesem Server aktuell nicht verfügbar.'); + } + + $apiKey = $this->apiKey(); + if ($apiKey === '') { + throw new RuntimeException('Für KI-Zusammenfassungen fehlt der OpenAI API-Key.'); + } + + $config = $this->config->get(); + $body = json_encode([ + 'model' => (string) ($config['model'] ?? 'gpt-4o-mini'), + 'messages' => [ + [ + 'role' => 'system', + 'content' => $systemPrompt, + ], + [ + 'role' => 'user', + 'content' => $userPrompt, + ], + ], + 'temperature' => 0.8, + 'max_completion_tokens' => 900, + 'store' => false, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if (!is_string($body)) { + throw new RuntimeException('Die KI-Anfrage konnte nicht vorbereitet werden.'); + } + + $handle = curl_init(self::CHAT_COMPLETIONS_ENDPOINT); + if ($handle === false) { + throw new RuntimeException('Die Verbindung zur KI konnte nicht vorbereitet werden.'); + } + + curl_setopt_array($handle, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $apiKey, + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_TIMEOUT => (int) ($config['timeout'] ?? 25), + ]); + + $responseBody = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $error = curl_error($handle); + curl_close($handle); + + if ($responseBody === false || $error !== '') { + throw new RuntimeException('Die KI-Anfrage ist fehlgeschlagen oder hat zu lange gedauert.'); + } + + $decoded = json_decode((string) $responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Die KI-Antwort konnte nicht gelesen werden.'); + } + + if ($status < 200 || $status >= 300) { + $message = trim((string) ($decoded['error']['message'] ?? '')); + if ($message === '') { + $message = 'Die KI-Anfrage konnte gerade nicht verarbeitet werden.'; + } + + throw new RuntimeException($message); + } + + $text = trim($this->extractResponseText($decoded)); + if ($text === '') { + throw new RuntimeException('Die KI hat keine verwertbare Zusammenfassung zurückgegeben.'); + } + + return $text; + } + + private function extractResponseText(array $response): string + { + $content = $response['choices'][0]['message']['content'] ?? null; + + if (is_string($content)) { + return $content; + } + + if (is_array($content)) { + $parts = []; + + foreach ($content as $item) { + if (is_string($item)) { + $parts[] = $item; + continue; + } + + if (is_array($item) && is_string($item['text'] ?? null)) { + $parts[] = (string) $item['text']; + } + } + + return trim(implode("\n\n", array_filter($parts))); + } + + return ''; + } + + private function renderTemplate(string $template, array $payload): string + { + $replacements = []; + + foreach ($payload as $key => $value) { + $replacements['{{' . strtoupper((string) $key) . '}}'] = is_scalar($value) + ? (string) $value + : ''; + } + + return strtr($template, $replacements); + } + + private function apiKey(): string + { + return trim((string) ($_ENV['OPENAI_API_KEY'] ?? getenv('OPENAI_API_KEY') ?: '')); + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php index 07756b2..aaa0639 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -6,11 +6,14 @@ require __DIR__ . '/helpers.php'; require __DIR__ . '/Support/Defaults.php'; require __DIR__ . '/Support/Auth.php'; require __DIR__ . '/Support/EntryCrypto.php'; +require __DIR__ . '/Support/OpenAiSummaryService.php'; require __DIR__ . '/Support/View.php'; require __DIR__ . '/Support/WebPushService.php'; +require __DIR__ . '/Domain/AiConfigRepository.php'; require __DIR__ . '/Domain/UserRepository.php'; require __DIR__ . '/Domain/SettingsRepository.php'; require __DIR__ . '/Domain/EntryRepository.php'; +require __DIR__ . '/Domain/SummaryRepository.php'; require __DIR__ . '/Domain/LoginThrottle.php'; require __DIR__ . '/Domain/NotificationRepository.php'; require __DIR__ . '/Domain/ScoringService.php'; diff --git a/src/helpers.php b/src/helpers.php index 82775f3..6c4e5da 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -197,6 +197,87 @@ function format_display_date(string $date, bool $withWeekday = true): string return $weekdays[(int) $current->format('w')] . ', ' . $label; } +function format_display_datetime(string $value): string +{ + try { + $current = new DateTimeImmutable($value); + } catch (Throwable) { + return $value; + } + + $months = [ + 1 => 'Januar', + 2 => 'Februar', + 3 => 'März', + 4 => 'April', + 5 => 'Mai', + 6 => 'Juni', + 7 => 'Juli', + 8 => 'August', + 9 => 'September', + 10 => 'Oktober', + 11 => 'November', + 12 => 'Dezember', + ]; + + return $current->format('j.') . ' ' . $months[(int) $current->format('n')] . ' ' . $current->format('Y') . ' um ' . $current->format('H:i'); +} + +function iso_week_key(string $date): string +{ + $current = DateTimeImmutable::createFromFormat('Y-m-d', $date); + + if ($current === false) { + return date('o-\K\W-W'); + } + + return $current->format('o-\K\W-W'); +} + +function month_key(string $date): string +{ + $current = DateTimeImmutable::createFromFormat('Y-m-d', $date); + + if ($current === false) { + return date('Y-m'); + } + + return $current->format('Y-m'); +} + +function iso_week_label(string $key): string +{ + if (preg_match('/^(\d{4})-KW-(\d{2})$/', $key, $matches) === 1) { + return 'KW ' . $matches[2] . ' / ' . $matches[1]; + } + + return $key; +} + +function month_label(string $key): string +{ + if (preg_match('/^(\d{4})-(\d{2})$/', $key, $matches) !== 1) { + return $key; + } + + $months = [ + '01' => 'Januar', + '02' => 'Februar', + '03' => 'März', + '04' => 'April', + '05' => 'Mai', + '06' => 'Juni', + '07' => 'Juli', + '08' => 'August', + '09' => 'September', + '10' => 'Oktober', + '11' => 'November', + '12' => 'Dezember', + ]; + + return ($months[$matches[2]] ?? $matches[2]) . ' ' . $matches[1]; +} + function icon_path(string $name): string { return '/assets/icons/' . $name . '.svg'; diff --git a/templates/pages/archive.php b/templates/pages/archive.php index 843ed7e..c1bd142 100644 --- a/templates/pages/archive.php +++ b/templates/pages/archive.php @@ -3,47 +3,181 @@

Archiv

-

Alle gespeicherten Tage

+

KI-Rückblicke und gespeicherte Tage

Einträge
- -

Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.

- -
- -
-
- - - 0 && !empty($entry['sport_type_meta'])): ?> - - - - - - - - - -
-
- - Stimmung /10 -
- -
- +
+
+
+

KI

+

Monatszusammenfassungen

+
+ + API nicht bereit +
- + + +

Sobald genügend Wochenzusammenfassungen vorliegen, erscheinen hier die Monatsrückblicke.

+ +
+ +
+
+
+ KI + +
+ + vorhanden + + offen + +
+ +

bis

+

KI-Wochenzusammenfassungen im Monat verfügbar

+ + +

Erstellt am

+ +

Mindestens 2 KI-Wochenzusammenfassungen nötig.

+ + +
+ + Öffnen + + +
+ + + + +
+
+
+ +
+ +
+ +
+
+
+

KI

+

Wochenzusammenfassungen

+
+
+ + +

Noch keine Wochen verfügbar. Sobald Einträge vorliegen, kannst du hier Wochenrückblicke erzeugen.

+ +
+ +
+
+
+ KI + +
+ + vorhanden + + offen + +
+ +

bis

+

Texteinträge · getrackte Tage

+ + +

Erstellt am

+ +

Mindestens 3 Texteinträge nötig.

+ + +
+ + Öffnen + + +
+ + + + +
+
+
+ +
+ +
+ +
+
+
+

Tage

+

Alle gespeicherten Tage

+
+
+ + +

Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.

+ +
+ +
+
+ + + 0 && !empty($entry['sport_type_meta'])): ?> + + + + + + + + + +
+
+ + Stimmung /10 +
+ +
+ +
+ +
diff --git a/templates/pages/options.php b/templates/pages/options.php index ff37746..2963fb8 100644 --- a/templates/pages/options.php +++ b/templates/pages/options.php @@ -390,6 +390,37 @@ +
+

KI

+

OpenAI für Zusammenfassungen

+

Diese Einstellung gilt zentral für alle Nutzer. Der API-Key bleibt in der Umgebung des Servers und wird hier bewusst nicht gespeichert.

+ + +
+
+ API-Key + +
+
+ Aktuelles Modell + +
+
+ Timeout + s +
+
+ + +
+ + + + + +
+
+

Mehrere Accounts

Neuen Nutzer anlegen