feat(templates): two more non-AI templates + split gallery into two sections

Closes out T1 with three templates per category as discussed. The
gallery now renders agent-templates and workbench-templates as two
distinct labeled sections — the earlier implicit "everything's a
template for an agent" framing is gone.

Seed handlers (new):
- apps/mana/apps/web/src/lib/modules/habits/seed.ts — title-based
  idempotency (there's no description column on LocalHabit). If a
  non-deleted habit with the same title exists, the seed is skipped.
- apps/mana/apps/web/src/lib/companion/goals/seed.ts — title-based
  idempotency on companionGoals where status !== 'abandoned'.
- Both pulled in via side-effect imports in missions/setup.ts so the
  handler registry is populated before any apply.

New templates:
- 🏋️ Fitness (wellness) — scene body/habits/stretch/sleep + 3 habit
  seeds (Täglich 30min Bewegung, 3× Woche Training, 2L Wasser) + 1
  goal seed (3 Workouts pro Woche). No agent.
- 💻 Deep Work (work) — scene todo/calendar/notes/times + 2 habit
  seeds (1 wichtigste Aufgabe pro Tag, 4h Deep Work pro Tag) + 1
  goal seed (20h Deep Work pro Woche). No agent.

Gallery two-section layout:
- Title "Templates" (not "Agent-Templates") — broader framing.
- Section 1: "🤖 Agent-Templates" — filters ALL_TEMPLATES where
  category ∈ {'ai','delight'}: Recherche-Agent, Kontext-Agent,
  Today-Agent.
- Section 2: "🎨 Workbench-Templates" — filters to the rest:
  Calmness, Fitness, Deep Work.
- Each section gets a short intro paragraph so users understand the
  distinction before scanning the cards.
- Cards themselves unchanged; rendering extracted into a
  {#snippet templateCard(t)} shared between both sections.
- Per-category arrays computed once at module-load time (const in
  <script>); no per-render filter cost.

Result: each section has 3 templates, categorised by "does this
create an AI agent" rather than by use-case. Keeps the separation
honest — Agent-Templates set up autonomous work; Workbench-Templates
set up the user's own workspace.

Tests: shared-ai 26/26, webapp svelte-check 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 11:45:40 +02:00
parent 4f76d3926e
commit 9161c0b3ab
7 changed files with 390 additions and 34 deletions

View file

@ -0,0 +1,62 @@
/**
* Goals module seed handler applied by the workbench-template
* applicator when a template includes `seeds.goals`.
*
* Idempotency: matched by exact-title. A non-deleted goal with the
* same title is treated as "already seeded". Users can rename the
* goal freely without breaking idempotency because we only check on
* apply, not on update.
*/
import { db } from '$lib/data/database';
import { registerSeedHandler, type SeedOutcome } from '$lib/data/ai/agents/seed-registry';
import { goalStore } from './store';
import type { LocalGoal, GoalMetric, GoalTarget } from './types';
interface GoalSeed {
title: string;
description?: string;
moduleId: string;
metric: GoalMetric;
target: GoalTarget;
}
registerSeedHandler({
moduleName: 'goals',
async apply(items) {
const outcomes: SeedOutcome[] = [];
const existing = await db.table<LocalGoal>('companionGoals').toArray();
const existingTitles = new Set(
existing.filter((g) => g.status !== 'abandoned').map((g) => g.title)
);
for (const item of items) {
const seed = item.data as GoalSeed;
if (existingTitles.has(seed.title)) {
outcomes.push({ stableId: item.stableId, outcome: 'skipped-exists' });
continue;
}
try {
await goalStore.create({
title: seed.title,
description: seed.description,
moduleId: seed.moduleId,
metric: seed.metric,
target: seed.target,
});
existingTitles.add(seed.title);
outcomes.push({ stableId: item.stableId, outcome: 'created' });
} catch (err) {
outcomes.push({
stableId: item.stableId,
outcome: 'failed',
error: err instanceof Error ? err.message : String(err),
});
}
}
return outcomes;
},
});

View file

@ -27,6 +27,8 @@ import { runAgentsBootstrap } from '../agents/bootstrap';
// handler to a rarely-used module slows down the hot path otherwise.
// See docs/plans/workbench-templates.md §T1.
import '$lib/modules/meditate/seed';
import '$lib/modules/habits/seed';
import '$lib/companion/goals/seed';
import type { AiPlanInput, AiPlanOutput } from './planner/types';
/** Default interval between tick scans. One minute is fine for foreground use. */

View file

@ -0,0 +1,60 @@
/**
* Habits module seed handler applied by the workbench-template
* applicator when a template includes `seeds.habits`.
*
* Idempotency: matched by exact-title. A non-deleted habit with the
* same title is treated as "already seeded" user may have already
* applied the template or typed a matching habit themselves. Either
* way we don't want to create a duplicate.
*/
import { habitTable } from './collections';
import type { LocalHabit } from './types';
import { registerSeedHandler, type SeedOutcome } from '$lib/data/ai/agents/seed-registry';
import { habitsStore } from './stores/habits.svelte';
interface HabitSeed {
title: string;
icon: string;
color: string;
targetPerDay?: number | null;
defaultDuration?: number | null;
}
registerSeedHandler({
moduleName: 'habits',
async apply(items) {
const outcomes: SeedOutcome[] = [];
const existing = (await habitTable.toArray()) as LocalHabit[];
const existingTitles = new Set(existing.filter((h) => !h.deletedAt).map((h) => h.title));
for (const item of items) {
const seed = item.data as HabitSeed;
if (existingTitles.has(seed.title)) {
outcomes.push({ stableId: item.stableId, outcome: 'skipped-exists' });
continue;
}
try {
await habitsStore.createHabit({
title: seed.title,
icon: seed.icon,
color: seed.color,
targetPerDay: seed.targetPerDay ?? null,
defaultDuration: seed.defaultDuration ?? null,
});
existingTitles.add(seed.title); // guard against intra-batch duplicates
outcomes.push({ stableId: item.stableId, outcome: 'created' });
} catch (err) {
outcomes.push({
stableId: item.stableId,
outcome: 'failed',
error: err instanceof Error ? err.message : String(err),
});
}
}
return outcomes;
},
});

View file

@ -15,6 +15,13 @@
import { applyTemplate } from '$lib/data/ai/agents/apply-template';
let selected = $state<AgentTemplate | null>(null);
const agentTemplates = ALL_TEMPLATES.filter(
(t) => t.category === 'ai' || t.category === 'delight'
);
const workbenchTemplates = ALL_TEMPLATES.filter(
(t) => t.category !== 'ai' && t.category !== 'delight'
);
let applying = $state(false);
let result = $state<{
agentName?: string;
@ -93,50 +100,72 @@
</script>
<svelte:head>
<title>Agent-Templates — Mana</title>
<title>Templates — Mana</title>
</svelte:head>
{#snippet templateCard(t: AgentTemplate)}
<button
type="button"
class="card"
class:selected={selected?.id === t.id}
style="--accent: {t.color}"
onclick={() => openDetail(t)}
>
<span class="avatar">{t.agent?.avatar ?? t.icon}</span>
<span class="label">{t.label}</span>
<span class="tagline">{t.tagline}</span>
<span class="meta">
{#if t.agent}<span class="chip">Agent</span>{/if}
{#if t.scene}<span class="chip">Scene</span>{/if}
{#if t.missions && t.missions.length > 0}
<span class="chip">{t.missions.length} Mission{t.missions.length !== 1 ? 'en' : ''}</span>
{/if}
{#if t.seeds}
{@const total = Object.values(t.seeds).reduce((s, items) => s + items.length, 0)}
<span class="chip">{total} Seed{total !== 1 ? 's' : ''}</span>
{/if}
</span>
</button>
{/snippet}
<div class="page">
<header class="header">
<button type="button" class="back" onclick={() => goto('/')}>
<ArrowLeft size={14} /><span>Zurück zum Workbench</span>
</button>
<h1>Agent-Templates</h1>
<h1>Templates</h1>
<p class="sub">
Vorgefertigte AI-Agenten, die sofort loslaufen. Jedes Template legt einen Agent, eine passende
Scene und eine Starter-Mission an — die Mission ist standardmäßig pausiert, damit du bewusst
Play drückst.
Vorgefertigte Setups für deinen Workbench. Wähle ein Template und du hast in einem Klick eine
passende Scene, optionale AI-Agenten und erste Daten in den richtigen Modulen.
</p>
</header>
<div class="grid">
{#each ALL_TEMPLATES as t (t.id)}
<button
type="button"
class="card"
class:selected={selected?.id === t.id}
style="--accent: {t.color}"
onclick={() => openDetail(t)}
>
<span class="avatar">{t.agent?.avatar ?? t.icon}</span>
<span class="label">{t.label}</span>
<span class="tagline">{t.tagline}</span>
<span class="meta">
{#if t.agent}<span class="chip">Agent</span>{/if}
{#if t.scene}<span class="chip">Scene</span>{/if}
{#if t.missions && t.missions.length > 0}
<span class="chip"
>{t.missions.length} Mission{t.missions.length !== 1 ? 'en' : ''}</span
>
{/if}
{#if t.seeds}
{@const total = Object.values(t.seeds).reduce((s, items) => s + items.length, 0)}
<span class="chip">{total} Seed{total !== 1 ? 's' : ''}</span>
{/if}
</span>
</button>
{/each}
</div>
<section class="section">
<div class="section-head">
<h2>🤖 Agent-Templates</h2>
<p>Benannte AI-Personas mit eigener Policy, Memory und Starter-Mission.</p>
</div>
<div class="grid">
{#each agentTemplates as t (t.id)}
{@render templateCard(t)}
{/each}
</div>
</section>
<section class="section">
<div class="section-head">
<h2>🎨 Workbench-Templates</h2>
<p>
Starter-Kits ohne AI — Scene-Layout + vor-gefüllte Habits, Goals und Module-Daten. Du
arbeitest selbst, das Template nimmt dir die Einrichtung ab.
</p>
</div>
<div class="grid">
{#each workbenchTemplates as t (t.id)}
{@render templateCard(t)}
{/each}
</div>
</section>
{#if selected}
<section class="detail" style="--accent: {selected.color}">
@ -350,6 +379,22 @@
max-width: 60ch;
line-height: 1.5;
}
.section {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.section-head h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.section-head p {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
max-width: 60ch;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));

View file

@ -0,0 +1,83 @@
import type { WorkbenchTemplate } from './types';
/**
* Deep Work work-category Workbench-Template.
*
* Ein Starter-Kit für konzentrierte Arbeitsphasen. Scene mit Todo /
* Calendar / Notes / Times + zwei Habits (Deep-Work-Zeit tracken, 1
* "wichtigste Aufgabe" pro Tag definieren) + ein Wochenziel für
* Fokus-Stunden.
*
* Kein AI-Agent. Das Template gibt die Struktur vor, der User bringt
* die Arbeit.
*/
export const deepWorkTemplate: WorkbenchTemplate = {
id: 'deep-work',
version: '1',
label: 'Deep Work',
icon: '💻',
tagline: 'Konzentrierter Arbeitsplatz mit Fokus-Tracking',
description: `Ein Starter-Kit für konzentrierte Arbeit. Legt dir eine Scene mit Todo, Kalender, Notes und Time-Tracking an und seed-et zwei Habits plus ein Wochenziel für Deep-Work-Stunden.
Was drin ist:
- **Scene**: Todo · Kalender · Notes · Times alles für fokussierte Sessions nebeneinander.
- **2 Habits**:
- 🎯 1 wichtigste Aufgabe pro Tag
- 4h Deep Work pro Tag
- **1 Wochenziel**: "20h Deep Work pro Woche" zählt abgeschlossene Time-Tracking-Sessions.
Kein Agent. Das Setup ist da; der Rest ist deine Disziplin.`,
category: 'work',
color: '#1F2937',
scene: {
name: 'Deep Work',
description: 'Fokus, Kalender, Notes, Zeit',
openApps: [
{ appId: 'todo', widthPx: 540 },
{ appId: 'calendar', widthPx: 540 },
{ appId: 'notes', widthPx: 440 },
{ appId: 'times', widthPx: 340 },
],
},
seeds: {
habits: [
{
stableId: 'template-deepwork:habit:top-task',
data: {
title: '1 wichtigste Aufgabe pro Tag',
icon: '🎯',
color: '#1F2937',
targetPerDay: 1,
defaultDuration: null,
},
},
{
stableId: 'template-deepwork:habit:deep-work-hours',
data: {
title: '4h Deep Work pro Tag',
icon: '⏱',
color: '#374151',
targetPerDay: 4,
defaultDuration: 60,
},
},
],
goals: [
{
stableId: 'template-deepwork:goal:weekly-focus',
data: {
title: '20h Deep Work pro Woche',
description: 'Summiert Time-Tracking-Sessions im Times-Modul.',
moduleId: 'times',
metric: {
source: 'event_count',
eventType: 'TimeSessionCompleted',
},
target: { value: 20, period: 'week', comparison: 'gte' },
},
},
],
},
};

View file

@ -0,0 +1,93 @@
import type { WorkbenchTemplate } from './types';
/**
* Fitness wellness-category Workbench-Template.
*
* Scene für Körper-Arbeit + drei Grund-Habits + ein Wochenziel. Keine
* AI. Der User trackt selbst; das Template nimmt ihm nur die initiale
* Einrichtung ab.
*
* Zielgruppe: jemand der "ich will regelmäßig trainieren" sagt und
* nicht erst 15 Minuten Setup-Zeit investieren will. 1-Klick-Setup.
*/
export const fitnessTemplate: WorkbenchTemplate = {
id: 'fitness',
version: '1',
label: 'Fitness',
icon: '🏋️',
tagline: 'Workout-Workspace mit Basis-Habits und Wochenziel',
description: `Ein Starter-Kit für regelmäßige Bewegung. Legt dir eine Scene mit Body, Habits, Stretch und Sleep an und seed-et drei Habits und ein Wochenziel — dann kannst du direkt loslegen.
Was drin ist:
- **Scene**: Body · Habits · Stretch · Sleep alles was du zum Tracken brauchst, nebeneinander.
- **3 Habits** mit sinnvollen Default-Icons:
- 🏃 Täglich 30min Bewegung
- 🏋 3× Woche Training
- 💧 2L Wasser täglich
- **1 Wochenziel**: "3 Workouts pro Woche" der Goal-Tracker zählt TaskCompleted-Events aus dem Body-Modul.
Kein Agent. Keine Automation. Du trainierst, die Workbench zählt.`,
category: 'wellness',
color: '#EF4444',
scene: {
name: 'Fitness',
description: 'Bewegung, Kraft, Schlaf',
openApps: [
{ appId: 'body', widthPx: 540 },
{ appId: 'habits', widthPx: 440 },
{ appId: 'stretch', widthPx: 340 },
{ appId: 'sleep', widthPx: 340 },
],
},
seeds: {
habits: [
{
stableId: 'template-fitness:habit:daily-movement',
data: {
title: 'Täglich 30min Bewegung',
icon: '🏃',
color: '#EF4444',
targetPerDay: 1,
defaultDuration: 30,
},
},
{
stableId: 'template-fitness:habit:weekly-training',
data: {
title: '3× Woche Training',
icon: '🏋️',
color: '#F97316',
targetPerDay: null,
defaultDuration: 60,
},
},
{
stableId: 'template-fitness:habit:hydration',
data: {
title: '2L Wasser täglich',
icon: '💧',
color: '#0EA5E9',
targetPerDay: 8,
defaultDuration: null,
},
},
],
goals: [
{
stableId: 'template-fitness:goal:weekly-workouts',
data: {
title: '3 Workouts pro Woche',
description: 'Zählt abgeschlossene Workouts im Body-Modul.',
moduleId: 'body',
metric: {
source: 'event_count',
eventType: 'TaskCompleted',
},
target: { value: 3, period: 'week', comparison: 'gte' },
},
},
],
},
};

View file

@ -11,6 +11,8 @@ import { researchTemplate } from './research';
import { contextTemplate } from './context';
import { todayTemplate } from './today';
import { calmnessTemplate } from './calmness';
import { fitnessTemplate } from './fitness';
import { deepWorkTemplate } from './deep-work';
export type {
// Generalised names (T1 of workbench-templates plan):
@ -34,9 +36,18 @@ export const ALL_TEMPLATES = [
contextTemplate,
todayTemplate,
calmnessTemplate,
fitnessTemplate,
deepWorkTemplate,
] as const;
export { researchTemplate, contextTemplate, todayTemplate, calmnessTemplate };
export {
researchTemplate,
contextTemplate,
todayTemplate,
calmnessTemplate,
fitnessTemplate,
deepWorkTemplate,
};
/** Lookup helper returns the template matching the given id, or
* undefined. Useful for deep-links `/agents/templates?pick=research`. */