mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(brain): add NudgeToast, server LLM fallback, trigger-event bridge
Three remaining TODOs resolved:
1. NudgeToast (in-app nudge display):
- New NudgeToast.svelte in bottom-stack alongside SuggestionToast
- Evaluates Pulse Rules every 60s, shows nudges as toasts
- Action button navigates to module route, dismiss records outcome
- Badge shows count when multiple nudges are queued
2. Server LLM fallback:
- Companion engine now tries local LLM (Gemma/WebGPU) first
- Falls back to mana-api /api/v1/chat/completions if no WebGPU
- isCompanionAvailable() returns true if either path works
- Graceful error messages when neither is available
3. Trigger-Event bridge (legacy automation migration):
- event-bridge.ts maps 13 domain event types to legacy
(appId, collection, op) format
- Existing user automations now fire on domain events too
- Domain events carry decrypted data → condition matching on
encrypted fields (title, etc.) works correctly
- Bridge wired into layout startup/cleanup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d6d50e4d94
commit
677f6b799d
6 changed files with 521 additions and 251 deletions
201
apps/mana/apps/web/src/lib/components/NudgeToast.svelte
Normal file
201
apps/mana/apps/web/src/lib/components/NudgeToast.svelte
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<!--
|
||||
NudgeToast — In-app display for Companion Brain Pulse nudges.
|
||||
Lives in .bottom-stack in (app)/+layout.svelte.
|
||||
Self-gates: only renders when there are active nudges.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { X } from '@mana/shared-icons';
|
||||
import { evaluateRules, dismissNudge } from '$lib/companion/rules';
|
||||
import { recordOutcome } from '$lib/companion/feedback';
|
||||
import { useDaySnapshot } from '$lib/data/projections/day-snapshot';
|
||||
import { useStreaks } from '$lib/data/projections/streaks';
|
||||
import type { Nudge, NudgeType } from '$lib/companion/rules/types';
|
||||
|
||||
const day = useDaySnapshot();
|
||||
const streaks = useStreaks();
|
||||
|
||||
let nudges = $state<Nudge[]>([]);
|
||||
let currentNudge = $derived(nudges[0] ?? null);
|
||||
let shownAt = $state(0);
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function checkNudges() {
|
||||
const results = evaluateRules(day.value, streaks.value, []);
|
||||
// Only show nudges we haven't already dismissed in this session
|
||||
nudges = results.filter((n) => !dismissed.has(n.id));
|
||||
}
|
||||
|
||||
const dismissed = new Set<string>();
|
||||
|
||||
onMount(() => {
|
||||
// Check every 60 seconds
|
||||
checkNudges();
|
||||
intervalId = setInterval(checkNudges, 60_000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
});
|
||||
|
||||
// Re-check when projections update
|
||||
$effect(() => {
|
||||
if (day.value.date && streaks.value) {
|
||||
checkNudges();
|
||||
}
|
||||
});
|
||||
|
||||
function handleAction() {
|
||||
if (!currentNudge) return;
|
||||
const latencyMs = Date.now() - shownAt;
|
||||
recordOutcome(currentNudge.id, currentNudge.type as NudgeType, 'acted', latencyMs);
|
||||
if (currentNudge.actionRoute) {
|
||||
goto(currentNudge.actionRoute);
|
||||
}
|
||||
removeCurrentNudge();
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
if (!currentNudge) return;
|
||||
const latencyMs = Date.now() - shownAt;
|
||||
recordOutcome(currentNudge.id, currentNudge.type as NudgeType, 'dismissed', latencyMs);
|
||||
dismissNudge(currentNudge.id);
|
||||
removeCurrentNudge();
|
||||
}
|
||||
|
||||
function removeCurrentNudge() {
|
||||
if (currentNudge) {
|
||||
dismissed.add(currentNudge.id);
|
||||
nudges = nudges.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (currentNudge) shownAt = Date.now();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if currentNudge}
|
||||
<div class="nudge-toast" role="alert">
|
||||
<div class="nudge-body">
|
||||
<span class="nudge-title">{currentNudge.title}</span>
|
||||
<span class="nudge-text">{currentNudge.body}</span>
|
||||
</div>
|
||||
<div class="nudge-actions">
|
||||
{#if currentNudge.actionLabel}
|
||||
<button class="nudge-action" onclick={handleAction}>
|
||||
{currentNudge.actionLabel}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="nudge-dismiss" onclick={handleDismiss} title="Ausblenden">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{#if nudges.length > 1}
|
||||
<span class="nudge-count">{nudges.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.nudge-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(30, 30, 40, 0.95);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.3);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(12px);
|
||||
max-width: min(90vw, 480px);
|
||||
margin: 0 auto;
|
||||
animation: slide-up 0.3s ease-out;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nudge-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nudge-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.nudge-text {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.nudge-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nudge-action {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nudge-action:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.nudge-dismiss {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
padding: 0.125rem;
|
||||
display: flex;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.nudge-dismiss:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.nudge-count {
|
||||
position: absolute;
|
||||
top: -0.375rem;
|
||||
right: -0.375rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,28 +1,78 @@
|
|||
/**
|
||||
* Companion Chat Engine — Orchestrates LLM + Context Document + Tool Calling.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Build system prompt from Context Document (projections + streaks)
|
||||
* 2. Collect conversation history
|
||||
* 3. Send to LLM with tool schemas
|
||||
* 4. If LLM returns tool_use → execute tool → feed result back → repeat
|
||||
* 5. Return final assistant message
|
||||
*
|
||||
* Currently uses @mana/local-llm directly (Gemma, browser-local).
|
||||
* Tool calling is simulated via JSON extraction since Gemma doesn't
|
||||
* natively support function calling — the system prompt instructs the
|
||||
* model to output JSON when it wants to call a tool.
|
||||
* Tries local LLM (Gemma via @mana/local-llm) first. If WebGPU is not
|
||||
* available, falls back to the mana-llm server endpoint. Tool calling
|
||||
* uses JSON extraction from the LLM output.
|
||||
*/
|
||||
|
||||
import { generate, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm';
|
||||
import { generate, getLocalLlmStatus, loadLocalLlm, isLocalLlmSupported } from '@mana/local-llm';
|
||||
import { generateContextDocument } from '$lib/data/projections/context-document';
|
||||
import { getToolsForLlm, executeTool } from '$lib/data/tools';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import type { DaySnapshot, StreakInfo } from '$lib/data/projections/types';
|
||||
import type { LocalMessage } from './types';
|
||||
import type { ToolResult } from '$lib/data/tools/types';
|
||||
|
||||
const MAX_TOOL_ROUNDS = 3;
|
||||
|
||||
type LlmMessage = { role: 'user' | 'assistant' | 'system'; content: string };
|
||||
|
||||
/** Try local LLM, fall back to server if WebGPU unavailable. */
|
||||
async function callLlm(messages: LlmMessage[], onToken?: (token: string) => void): Promise<string> {
|
||||
// Try local first (WebGPU + Gemma)
|
||||
if (isLocalLlmSupported()) {
|
||||
const status = getLocalLlmStatus();
|
||||
if (status.current.state !== 'ready') {
|
||||
try {
|
||||
await loadLocalLlm();
|
||||
} catch {
|
||||
// Fall through to server
|
||||
return callServerLlm(messages);
|
||||
}
|
||||
}
|
||||
const result = await generate({ messages, temperature: 0.7, maxTokens: 1024, onToken });
|
||||
return result.content;
|
||||
}
|
||||
|
||||
// Fallback: server-side LLM via mana-api
|
||||
return callServerLlm(messages);
|
||||
}
|
||||
|
||||
async function callServerLlm(messages: LlmMessage[]): Promise<string> {
|
||||
const apiUrl =
|
||||
(typeof window !== 'undefined' &&
|
||||
(window as unknown as Record<string, string>).__PUBLIC_MANA_API_URL__) ||
|
||||
import.meta.env.PUBLIC_MANA_API_URL ||
|
||||
'';
|
||||
|
||||
if (!apiUrl) {
|
||||
return 'LLM nicht verfuegbar — weder WebGPU noch Server-Endpoint konfiguriert.';
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
try {
|
||||
const token = await authStore.getValidToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
} catch {
|
||||
// Continue without auth — server will decide
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ messages, model: 'companion' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text().catch(() => response.statusText);
|
||||
return `Server-Fehler: ${err}`;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
return data.choices?.[0]?.message?.content ?? 'Keine Antwort vom Server.';
|
||||
}
|
||||
|
||||
interface EngineResult {
|
||||
content: string;
|
||||
toolCalls: { name: string; params: Record<string, unknown>; result: ToolResult }[];
|
||||
|
|
@ -107,17 +157,10 @@ export async function runCompanionChat(
|
|||
streaks: StreakInfo[],
|
||||
onToken?: (token: string) => void
|
||||
): Promise<EngineResult> {
|
||||
// Ensure local LLM is loaded
|
||||
const status = getLocalLlmStatus();
|
||||
if (status.current.state !== 'ready') {
|
||||
await loadLocalLlm();
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(day, streaks);
|
||||
const toolCalls: EngineResult['toolCalls'] = [];
|
||||
|
||||
// Build message chain
|
||||
const llmMessages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [
|
||||
const llmMessages: LlmMessage[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messagesToLlm(history),
|
||||
{ role: 'user', content: userMessage },
|
||||
|
|
@ -126,14 +169,7 @@ export async function runCompanionChat(
|
|||
let finalContent = '';
|
||||
|
||||
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
|
||||
const result = await generate({
|
||||
messages: llmMessages,
|
||||
temperature: 0.7,
|
||||
maxTokens: 1024,
|
||||
onToken: round === 0 ? onToken : undefined, // Only stream first round
|
||||
});
|
||||
|
||||
const text = result.content;
|
||||
const text = await callLlm(llmMessages, round === 0 ? onToken : undefined);
|
||||
const toolCall = extractToolCall(text);
|
||||
|
||||
if (!toolCall) {
|
||||
|
|
@ -168,9 +204,20 @@ export async function runCompanionChat(
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if the Companion Chat is available (LLM loaded or loadable).
|
||||
* Check if the Companion Chat is available.
|
||||
* Returns true if either local LLM or server endpoint is usable.
|
||||
*/
|
||||
export function isCompanionAvailable(): boolean {
|
||||
const status = getLocalLlmStatus();
|
||||
return status.current.state === 'ready' || status.current.state === 'idle';
|
||||
// Local LLM available?
|
||||
if (isLocalLlmSupported()) {
|
||||
const status = getLocalLlmStatus();
|
||||
if (status.current.state === 'ready' || status.current.state === 'idle') return true;
|
||||
}
|
||||
// Server fallback configured?
|
||||
const apiUrl =
|
||||
(typeof window !== 'undefined' &&
|
||||
(window as unknown as Record<string, string>).__PUBLIC_MANA_API_URL__) ||
|
||||
import.meta.env.PUBLIC_MANA_API_URL ||
|
||||
'';
|
||||
return !!apiUrl;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,14 +54,14 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if showQuickLog}
|
||||
<QuickLog
|
||||
onComplete={() => (showQuickLog = false)}
|
||||
onCancel={() => (showQuickLog = false)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="mood-view">
|
||||
<!-- Log CTA -->
|
||||
<div class="mood-view">
|
||||
<!-- Inline Quick Log (expand/collapse) -->
|
||||
{#if showQuickLog}
|
||||
<QuickLog
|
||||
onComplete={() => (showQuickLog = false)}
|
||||
onCancel={() => (showQuickLog = false)}
|
||||
/>
|
||||
{:else}
|
||||
<button class="log-cta" onclick={() => (showQuickLog = true)}>
|
||||
<span class="cta-emoji">
|
||||
{#if topEmotion}
|
||||
|
|
@ -75,6 +75,7 @@
|
|||
{todayEntries.length}/{settings.dailyTarget} Check-ins heute
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Today's Entries -->
|
||||
{#if todayEntries.length > 0}
|
||||
|
|
@ -209,8 +210,7 @@
|
|||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mood-view {
|
||||
|
|
@ -231,28 +231,26 @@
|
|||
/* ── Log CTA ─────────────────────────────────── */
|
||||
.log-cta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: linear-gradient(135deg, hsl(40 80% 96%), hsl(350 60% 96%));
|
||||
border: 1px solid hsl(40 60% 88%);
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
color: hsl(var(--color-foreground));
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:global(.dark) .log-cta {
|
||||
background: linear-gradient(135deg, hsl(40 30% 12%), hsl(350 25% 14%));
|
||||
border-color: hsl(40 30% 20%);
|
||||
.log-cta:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.log-cta:hover { transform: scale(1.02); }
|
||||
|
||||
.cta-emoji { font-size: 1.75rem; }
|
||||
.cta-text { font-size: 0.875rem; font-weight: 600; }
|
||||
.cta-sub { font-size: 0.6875rem; color: #f59e0b; font-weight: 500; }
|
||||
.cta-emoji { font-size: 1.375rem; flex-shrink: 0; }
|
||||
.cta-text { font-size: 0.8125rem; font-weight: 600; flex: 1; }
|
||||
.cta-sub { font-size: 0.6875rem; color: #f59e0b; font-weight: 500; flex-shrink: 0; }
|
||||
|
||||
/* ── Today ────────────────────────────────────── */
|
||||
.today-section {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<!--
|
||||
QuickLog — Fast mood check-in: level slider, emotion pick, optional context.
|
||||
QuickLog — Inline mood check-in card (not fullscreen).
|
||||
Level slider, emotion pick, optional context. Collapses after save.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { moodStore } from '../stores/mood.svelte';
|
||||
|
|
@ -26,7 +27,6 @@
|
|||
let selectedTags = $state<string[]>([]);
|
||||
let showDetails = $state(false);
|
||||
|
||||
// Split emotions by valence for the picker layout
|
||||
let positiveEmotions = $derived(CORE_EMOTIONS.filter((e) => EMOTION_META[e].valence === 'positive'));
|
||||
let neutralEmotions = $derived(CORE_EMOTIONS.filter((e) => EMOTION_META[e].valence === 'neutral'));
|
||||
let negativeEmotions = $derived(CORE_EMOTIONS.filter((e) => EMOTION_META[e].valence === 'negative'));
|
||||
|
|
@ -48,6 +48,13 @@
|
|||
notes,
|
||||
tags: selectedTags,
|
||||
});
|
||||
// Reset for next entry
|
||||
level = 5;
|
||||
emotion = null;
|
||||
activity = null;
|
||||
notes = '';
|
||||
selectedTags = [];
|
||||
showDetails = false;
|
||||
onComplete();
|
||||
}
|
||||
|
||||
|
|
@ -60,166 +67,134 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="log-overlay">
|
||||
<div class="log-header">
|
||||
<button class="close-btn" onclick={onCancel}>×</button>
|
||||
<span class="header-title">Wie geht es dir?</span>
|
||||
<div class="quick-log">
|
||||
<div class="ql-header">
|
||||
<span class="ql-title">Wie geht es dir?</span>
|
||||
<button class="ql-close" onclick={onCancel}>×</button>
|
||||
</div>
|
||||
|
||||
<div class="log-body">
|
||||
<!-- Level Slider -->
|
||||
<div class="level-section">
|
||||
<div class="level-display" style:color={levelColor(level)}>
|
||||
{level}
|
||||
</div>
|
||||
<input
|
||||
class="level-slider"
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
bind:value={level}
|
||||
style:accent-color={levelColor(level)}
|
||||
/>
|
||||
<div class="level-labels">
|
||||
<span>Schlecht</span>
|
||||
<span>Super</span>
|
||||
</div>
|
||||
<!-- Level Slider -->
|
||||
<div class="level-section">
|
||||
<div class="level-display" style:color={levelColor(level)}>{level}</div>
|
||||
<input
|
||||
class="level-slider"
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
bind:value={level}
|
||||
style:accent-color={levelColor(level)}
|
||||
/>
|
||||
<div class="level-labels">
|
||||
<span>Schlecht</span>
|
||||
<span>Super</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emotion Picker -->
|
||||
<div class="emotion-section">
|
||||
<span class="section-label">Was fühlst du?</span>
|
||||
<div class="emotion-grid">
|
||||
{#each positiveEmotions as e}
|
||||
<button
|
||||
class="emotion-btn"
|
||||
class:selected={emotion === e}
|
||||
onclick={() => (emotion = emotion === e ? null : e)}
|
||||
>
|
||||
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
|
||||
<span class="emo-label">{EMOTION_META[e].de}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each neutralEmotions as e}
|
||||
<button
|
||||
class="emotion-btn"
|
||||
class:selected={emotion === e}
|
||||
onclick={() => (emotion = emotion === e ? null : e)}
|
||||
>
|
||||
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
|
||||
<span class="emo-label">{EMOTION_META[e].de}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each negativeEmotions as e}
|
||||
<button
|
||||
class="emotion-btn"
|
||||
class:selected={emotion === e}
|
||||
onclick={() => (emotion = emotion === e ? null : e)}
|
||||
>
|
||||
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
|
||||
<span class="emo-label">{EMOTION_META[e].de}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Emotion Picker -->
|
||||
<div class="emotion-section">
|
||||
<span class="section-label">Was fühlst du?</span>
|
||||
<div class="emotion-grid">
|
||||
{#each [...positiveEmotions, ...neutralEmotions, ...negativeEmotions] as e}
|
||||
<button
|
||||
class="emotion-btn"
|
||||
class:selected={emotion === e}
|
||||
onclick={() => (emotion = emotion === e ? null : e)}
|
||||
>
|
||||
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
|
||||
<span class="emo-label">{EMOTION_META[e].de}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Toggle -->
|
||||
{#if !showDetails}
|
||||
<button class="details-toggle" onclick={() => (showDetails = true)}>
|
||||
+ Details hinzufügen
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Activity -->
|
||||
<div class="activity-section">
|
||||
<span class="section-label">Was machst du gerade?</span>
|
||||
<div class="activity-grid">
|
||||
{#each Object.entries(ACTIVITY_LABELS) as [key, meta]}
|
||||
<button
|
||||
class="activity-btn"
|
||||
class:selected={activity === key}
|
||||
onclick={() => (activity = activity === key ? null : (key as ActivityContext))}
|
||||
>
|
||||
<span class="act-emoji">{meta.emoji}</span>
|
||||
<span class="act-label">{meta.de}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="tags-section">
|
||||
<span class="section-label">Tags</span>
|
||||
<div class="tags-row">
|
||||
{#each MOOD_TAG_PRESETS as tag}
|
||||
<button
|
||||
class="tag-chip"
|
||||
class:active={selectedTags.includes(tag)}
|
||||
onclick={() => toggleTag(tag)}
|
||||
>{tag}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<textarea
|
||||
class="notes-input"
|
||||
placeholder="Notizen (optional)..."
|
||||
bind:value={notes}
|
||||
rows="2"
|
||||
></textarea>
|
||||
{/if}
|
||||
|
||||
<!-- Save -->
|
||||
<button class="save-btn" onclick={handleSave} disabled={!emotion}>
|
||||
Speichern
|
||||
<!-- Details Toggle -->
|
||||
{#if !showDetails}
|
||||
<button class="details-toggle" onclick={() => (showDetails = true)}>
|
||||
+ Details hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Activity -->
|
||||
<div class="activity-section">
|
||||
<span class="section-label">Was machst du gerade?</span>
|
||||
<div class="activity-grid">
|
||||
{#each Object.entries(ACTIVITY_LABELS) as [key, meta]}
|
||||
<button
|
||||
class="activity-btn"
|
||||
class:selected={activity === key}
|
||||
onclick={() => (activity = activity === key ? null : (key as ActivityContext))}
|
||||
>
|
||||
<span class="act-emoji">{meta.emoji}</span>
|
||||
<span class="act-label">{meta.de}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="tags-section">
|
||||
<span class="section-label">Tags</span>
|
||||
<div class="tags-row">
|
||||
{#each MOOD_TAG_PRESETS as tag}
|
||||
<button
|
||||
class="tag-chip"
|
||||
class:active={selectedTags.includes(tag)}
|
||||
onclick={() => toggleTag(tag)}
|
||||
>{tag}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<textarea
|
||||
class="notes-input"
|
||||
placeholder="Notizen (optional)..."
|
||||
bind:value={notes}
|
||||
rows="2"
|
||||
></textarea>
|
||||
{/if}
|
||||
|
||||
<!-- Save -->
|
||||
<button class="save-btn" onclick={handleSave} disabled={!emotion}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.log-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: hsl(var(--color-background));
|
||||
.quick-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.ql-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ql-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.ql-close {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-border));
|
||||
border: none;
|
||||
font-size: 1.125rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.log-body {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
|
|
@ -235,11 +210,11 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.level-display {
|
||||
font-size: 2.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
|
|
@ -247,7 +222,6 @@
|
|||
|
||||
.level-slider {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +229,6 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
font-size: 0.5625rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
|
@ -264,32 +237,30 @@
|
|||
.emotion-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.emotion-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
||||
gap: 0.375rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.emotion-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.375rem 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
gap: 0.0625rem;
|
||||
padding: 0.25rem 0.125rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.15s;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.emotion-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.emotion-btn:hover { transform: scale(1.05); }
|
||||
|
||||
.emotion-btn.selected {
|
||||
border-color: #f59e0b;
|
||||
|
|
@ -297,30 +268,22 @@
|
|||
}
|
||||
|
||||
:global(.dark) .emotion-btn.selected {
|
||||
background: hsl(40 30% 12%);
|
||||
background: hsl(40 30% 15%);
|
||||
}
|
||||
|
||||
.emo-emoji {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emo-label {
|
||||
font-size: 0.5625rem;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.emo-emoji { font-size: 1rem; line-height: 1; }
|
||||
.emo-label { font-size: 0.5rem; text-align: center; line-height: 1.1; }
|
||||
|
||||
/* ── Activity ─────────────────────────────────── */
|
||||
.activity-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.activity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
|
|
@ -328,48 +291,42 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.0625rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.375rem;
|
||||
gap: 0;
|
||||
padding: 0.1875rem;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
cursor: pointer;
|
||||
font-size: 0.5625rem;
|
||||
font-size: 0.5rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.activity-btn.selected {
|
||||
background: hsl(var(--color-muted));
|
||||
background: hsl(var(--color-background));
|
||||
border-color: #f59e0b;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.act-emoji {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.act-label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
.act-emoji { font-size: 0.875rem; }
|
||||
.act-label { line-height: 1.1; }
|
||||
|
||||
/* ── Tags & Notes ─────────────────────────────── */
|
||||
.tags-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tags-row {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
gap: 0.1875rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.625rem;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
|
|
@ -385,12 +342,12 @@
|
|||
|
||||
.notes-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.75rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
|
|
@ -401,11 +358,11 @@
|
|||
|
||||
.details-toggle {
|
||||
text-align: center;
|
||||
padding: 0.375rem;
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
|
@ -417,12 +374,12 @@
|
|||
|
||||
/* ── Save ─────────────────────────────────────── */
|
||||
.save-btn {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
57
apps/mana/apps/web/src/lib/triggers/event-bridge.ts
Normal file
57
apps/mana/apps/web/src/lib/triggers/event-bridge.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Event Bridge — Connects Domain Events to the legacy Trigger system.
|
||||
*
|
||||
* Maps domain event types to the old (appId, collection, op) format
|
||||
* so existing user-created automations fire on semantic events (which
|
||||
* carry decrypted data) instead of only on raw Dexie hooks (which see
|
||||
* ciphertext for encrypted fields).
|
||||
*
|
||||
* This is a migration bridge. Long-term, automations should be
|
||||
* rewritten as Pulse Rules that subscribe to the Event Bus directly.
|
||||
*/
|
||||
|
||||
import { eventBus } from '$lib/data/events/event-bus';
|
||||
import { fire } from './registry';
|
||||
import type { DomainEvent } from '$lib/data/events/types';
|
||||
|
||||
/** Map domain event type → legacy (appId, collection, op) */
|
||||
const EVENT_MAP: Record<string, { appId: string; collection: string; op: string }> = {
|
||||
TaskCreated: { appId: 'todo', collection: 'tasks', op: 'insert' },
|
||||
TaskCompleted: { appId: 'todo', collection: 'tasks', op: 'update' },
|
||||
TaskDeleted: { appId: 'todo', collection: 'tasks', op: 'delete' },
|
||||
CalendarEventCreated: { appId: 'calendar', collection: 'events', op: 'insert' },
|
||||
CalendarEventDeleted: { appId: 'calendar', collection: 'events', op: 'delete' },
|
||||
DrinkLogged: { appId: 'drink', collection: 'drinkEntries', op: 'insert' },
|
||||
HabitLogged: { appId: 'habits', collection: 'habitLogs', op: 'insert' },
|
||||
HabitCreated: { appId: 'habits', collection: 'habits', op: 'insert' },
|
||||
JournalEntryCreated: { appId: 'journal', collection: 'journalEntries', op: 'insert' },
|
||||
NoteCreated: { appId: 'notes', collection: 'notes', op: 'insert' },
|
||||
ContactCreated: { appId: 'contacts', collection: 'contacts', op: 'insert' },
|
||||
PlaceVisited: { appId: 'places', collection: 'places', op: 'update' },
|
||||
MealLogged: { appId: 'nutriphi', collection: 'meals', op: 'insert' },
|
||||
};
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Start bridging domain events to the legacy trigger system.
|
||||
* Call once at app startup (after loadAutomations).
|
||||
*/
|
||||
export function startEventBridge(): void {
|
||||
if (unsubscribe) return;
|
||||
|
||||
unsubscribe = eventBus.onAny((event: DomainEvent) => {
|
||||
const mapping = EVENT_MAP[event.type];
|
||||
if (!mapping) return;
|
||||
|
||||
// Pass the domain event payload as the trigger data.
|
||||
// This is decrypted (unlike the Dexie hook data), so
|
||||
// condition matching on encrypted fields (title, etc.) works.
|
||||
fire(mapping.appId, mapping.collection, mapping.op, event.payload as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
export function stopEventBridge(): void {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
|
@ -7,11 +7,13 @@
|
|||
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
|
||||
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
|
||||
import { initTools } from '$lib/data/tools/init';
|
||||
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
|
||||
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
|
||||
import SessionWarning from '$lib/components/SessionWarning.svelte';
|
||||
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
|
||||
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
|
||||
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
|
||||
import NudgeToast from '$lib/components/NudgeToast.svelte';
|
||||
import { locale, _ } from 'svelte-i18n';
|
||||
import {
|
||||
PillNavigation,
|
||||
|
|
@ -421,6 +423,7 @@
|
|||
initSharedUload();
|
||||
startEventStore();
|
||||
initTools();
|
||||
startEventBridge();
|
||||
await dashboardStore.initialize();
|
||||
|
||||
// Start the persistent LLM task queue. Idempotent — safe to call
|
||||
|
|
@ -522,6 +525,7 @@
|
|||
unifiedSync?.stopAll();
|
||||
reminderScheduler.stop();
|
||||
stopEventStore();
|
||||
stopEventBridge();
|
||||
guestMode?.destroy();
|
||||
// Fire-and-forget — we don't need to await; the in-flight task
|
||||
// will finish in the background and the next page session will
|
||||
|
|
@ -702,6 +706,12 @@
|
|||
<SuggestionToast />
|
||||
</div>
|
||||
|
||||
<!-- Companion Brain pulse nudges — water reminders, streak
|
||||
warnings, morning summary etc. Self-gates on active nudges. -->
|
||||
<div class="bottom-stack-notification">
|
||||
<NudgeToast />
|
||||
</div>
|
||||
|
||||
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
|
||||
{#if isQuickInputVisible}
|
||||
<QuickInputBar
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue