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));