feat(ai-agents): Template gallery — 3 ready-to-use agent bundles

First pass of the Multi-Agent discoverability UX. A new /agents/
templates route showcases pre-configured agents; clicking one creates
agent + scene + starter mission(s) as a single bundle. Addresses the
"blank form anxiety" + "user doesn't know what agents are for"
observations from the UX brainstorm.

Three templates for v1 (shared-ai/src/agents/templates/):
- 🔍 Recherche-Agent — reads sources one by one, writes a note per
  source, summarizes into a report. Manual-cadence mission; all
  writes propose so user curates.
- 🧭 Kontext-Agent — learns about the user via a weekly check-in.
  Reads kontext/notes/goals, asks 2-3 questions, proposes a diff-
  style context update. Weekly Sunday cadence.
- 🌅 Today-Agent — researches "on this day" history each morning,
  writes a 4-8 line German poem, proposes a journal note. Daily 7am
  cadence. A "delight" agent, not a productive one.

Each template packs (agent config, scene layout, starter mission):
- AgentTemplate type lives in @mana/shared-ai — pure data, no runtime
  imports. Adding a new template = drop a file in templates/ and
  extend ALL_TEMPLATES.
- Template-specific policies derive from the proposable-tool list so
  drift-guard catches divergence from the canonical set.
- Starter missions default to startPaused=true — user sees the
  mission ready-to-go and hits Play when ready. Prevents surprise
  autonomous work on first apply.

Applicator (data/ai/agents/apply-template.ts):
- Creates agent → scene (if template defines one) → missions in
  order. Agent failure = abort; scene/mission failures surface as
  warnings in the result without blocking.
- Duplicate-name handling: falls through to findByName, returns
  existing agent with wasExisting=true; scene is skipped in that
  case to avoid clone-proliferation.

Gallery page /(app)/agents/templates/+page.svelte:
- Three large cards side-by-side (stacks on mobile) with avatar /
  label / tagline / meta chips (Scene, N Missionen).
- Click opens detail panel with full description, scene preview
  (app-ids + widths), mission preview (title / objective / cadence),
  and override checkboxes (create scene, create missions, start
  active vs paused).
- Success panel shows what landed with warnings inline; CTA back to
  workbench.

Discoverability in /ai-agents module:
- Bar now has two buttons: "Aus Template" (primary, goto templates
  route) + "Eigener Agent" (secondary, opens the existing blank-form
  create mode).
- When only the default "Mana" agent exists, render a dashed promo
  banner at the top linking to the template gallery. Disappears as
  soon as the user has a second agent.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 00:36:39 +02:00
parent 4d9b16a683
commit 7822340ea0
10 changed files with 1150 additions and 6 deletions

View file

@ -0,0 +1,154 @@
/**
* Template applicator turns an AgentTemplate from `@mana/shared-ai`
* into concrete Dexie records: an Agent, optionally a workbench Scene,
* optionally starter Missions.
*
* Ordering matters: agent first (so mission.agentId can reference it),
* then scene (so `setActive` lands on a scene that contains the
* relevant apps), then missions (so they show up under the agent).
*
* Error semantics: failures bubble up but the ones that happened
* before are NOT rolled back user is told what did and didn't land.
* Pure-transaction semantics aren't worth the wrapper complexity for
* a 3-step sequence that is already idempotent:
* - duplicate agent name returns existing agent (getOrCreate-ish)
* - scene creation is a fresh insert, no dedup needed
* - missions use fresh UUIDs, no dedup needed
*/
import { createAgent, findByName, DuplicateAgentNameError } from './store';
import { createMission, pauseMission } from '../missions/store';
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
import type { AgentTemplate } from '@mana/shared-ai';
import type { Agent } from './types';
export interface ApplyTemplateOptions {
/** Create the template's scene + set it active. Default true when the
* template defines a scene; false when it doesn't. */
createScene?: boolean;
/** Create the template's starter missions. Default true. */
createMissions?: boolean;
/** When true, starter missions are left in whatever `startPaused`
* the template declares (usually paused). When false, override to
* active Power-User opt-in that skips the "click Play" step. */
respectPauseHint?: boolean;
}
export interface ApplyTemplateResult {
/** The agent that was created OR the pre-existing agent with the
* same name that we re-used. `wasExisting` tells you which. */
readonly agent: Agent;
readonly wasExisting: boolean;
readonly sceneId?: string;
readonly missionIds: readonly string[];
/** Any non-fatal errors from the sequence. Agent is guaranteed when
* this array is empty on agent slot; scene/mission failures still
* return here so the UI can surface them without blocking. */
readonly warnings: readonly string[];
}
/**
* Apply a template end-to-end. Returns a result object describing what
* actually landed in Dexie. Call sites render a success panel or a
* partial-failure panel based on `warnings` + presence of each field.
*/
export async function applyTemplate(
template: AgentTemplate,
opts: ApplyTemplateOptions = {}
): Promise<ApplyTemplateResult> {
const {
createScene = template.scene !== undefined,
createMissions = true,
respectPauseHint = true,
} = opts;
const warnings: string[] = [];
// 1. Agent — the only required piece. If duplicate name, re-use the
// existing agent (idempotent "apply twice" behavior).
let agent: Agent;
let wasExisting = false;
try {
agent = await createAgent({
name: template.agent.name,
avatar: template.agent.avatar,
role: template.agent.role,
systemPrompt: template.agent.systemPrompt,
memory: template.agent.memory,
policy: template.agent.policy,
maxTokensPerDay: template.agent.maxTokensPerDay,
maxConcurrentMissions: template.agent.maxConcurrentMissions,
});
} catch (err) {
if (err instanceof DuplicateAgentNameError) {
const existing = await findByName(template.agent.name);
if (!existing) {
throw err;
}
agent = existing;
wasExisting = true;
warnings.push(
`Ein Agent mit Namen "${template.agent.name}" existiert bereits — Template nutzt diesen.`
);
} else {
throw err;
}
}
// 2. Scene — skipped on re-apply so we don't generate Scene-Clones
// on every click.
let sceneId: string | undefined;
if (createScene && template.scene && !wasExisting) {
try {
sceneId = await workbenchScenesStore.createScene({
name: template.scene.name,
description: template.scene.description ?? null,
seedApps: [...template.scene.openApps],
setActive: true,
});
} catch (err) {
warnings.push(
`Scene konnte nicht angelegt werden: ${err instanceof Error ? err.message : String(err)}`
);
}
} else if (createScene && wasExisting) {
warnings.push(
'Scene übersprungen weil der Agent schon existierte — öffne die Scene manuell falls gewünscht.'
);
}
// 3. Missions — paused by default per template hint. Reapply on an
// existing agent is idempotent-ish: we create NEW missions (they
// have fresh UUIDs) but the UI should make that obvious.
const missionIds: string[] = [];
if (createMissions && template.missions) {
for (const m of template.missions) {
try {
const mission = await createMission({
title: m.title,
objective: m.objective,
conceptMarkdown: m.conceptMarkdown,
cadence: m.cadence,
inputs: m.inputs ? [...m.inputs] : undefined,
agentId: agent.id,
});
if (respectPauseHint && m.startPaused !== false) {
await pauseMission(mission.id);
}
missionIds.push(mission.id);
} catch (err) {
warnings.push(
`Starter-Mission "${m.title}" fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`
);
}
}
}
return {
agent,
wasExisting,
sceneId,
missionIds,
warnings,
};
}

View file

@ -17,8 +17,10 @@
handle the common "let the agent touch todo but not calendar" case.
-->
<script lang="ts">
import { ArrowLeft, Plus, Pause, Play, Archive, Trash } from '@mana/shared-icons';
import { ArrowLeft, Plus, Pause, Play, Archive, Trash, Sparkle } from '@mana/shared-icons';
import { goto } from '$app/navigation';
import { useAgents } from '$lib/data/ai/agents/queries';
import { DEFAULT_AGENT_ID } from '@mana/shared-ai';
import {
createAgent,
updateAgent,
@ -215,16 +217,34 @@
</script>
{#if mode === 'list'}
{@const onlyDefaultAgent = agents.value.length === 1 && agents.value[0].id === DEFAULT_AGENT_ID}
<div class="pane">
<header class="bar">
<button type="button" class="primary" onclick={() => (mode = 'create')}>
<Plus size={14} /><span>Neuer Agent</span>
<button type="button" class="primary" onclick={() => goto('/agents/templates')}>
<Sparkle size={14} /><span>Aus Template</span>
</button>
<button type="button" class="secondary" onclick={() => (mode = 'create')}>
<Plus size={14} /><span>Eigener Agent</span>
</button>
</header>
{#if onlyDefaultAgent}
<button type="button" class="promo" onclick={() => goto('/agents/templates')}>
<span class="promo-icon"><Sparkle size={16} weight="fill" /></span>
<span class="promo-body">
<strong>Starte mit einem Template</strong>
<span class="promo-sub">
Recherche · Kontext · Today — vorgefertigte Agenten mit passender Scene und
Starter-Mission.
</span>
</span>
</button>
{/if}
{#if agents.value.length === 0}
<p class="empty">
Noch keine Agenten. Ein Default-Agent „Mana" wird beim ersten Login automatisch angelegt;
für weitere persona-basierte Agenten klicke auf „Neuer Agent".
für weitere persona-basierte Agenten klicke auf „Aus Template" oder „Eigener Agent".
</p>
{:else}
<ul class="m-list">
@ -446,6 +466,7 @@
.bar {
display: flex;
justify-content: flex-end;
gap: 0.375rem;
}
.primary {
display: inline-flex;
@ -463,6 +484,52 @@
.primary:disabled {
opacity: 0.5;
}
.secondary {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.promo {
display: flex;
gap: 0.625rem;
align-items: center;
width: 100%;
padding: 0.75rem 0.875rem;
border: 1px dashed color-mix(in oklab, hsl(var(--color-primary)) 50%, transparent);
border-radius: 0.5rem;
background: color-mix(in oklab, hsl(var(--color-primary)) 6%, hsl(var(--color-surface)));
color: hsl(var(--color-foreground));
text-align: left;
cursor: pointer;
font: inherit;
}
.promo:hover {
background: color-mix(in oklab, hsl(var(--color-primary)) 10%, hsl(var(--color-surface)));
}
.promo-icon {
color: hsl(var(--color-primary));
flex-shrink: 0;
}
.promo-body {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.promo-body strong {
font-size: 0.875rem;
}
.promo-sub {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.empty {
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));

View file

@ -0,0 +1,512 @@
<!--
Agent-Templates Gallery — /agents/templates
Landing page for picking a pre-configured AI agent. Three cards
side-by-side on desktop, stacked on mobile. Clicking a card opens a
detail panel with description + "apply" options.
Designed as a standalone route (not an AppPage) so it can be deep-
linked from /welcome, from in-module banners, or shared directly.
-->
<script lang="ts">
import { ArrowLeft, Check, Play } from '@mana/shared-icons';
import { goto } from '$app/navigation';
import { ALL_TEMPLATES, type AgentTemplate } from '@mana/shared-ai';
import { applyTemplate } from '$lib/data/ai/agents/apply-template';
let selected = $state<AgentTemplate | null>(null);
let applying = $state(false);
let result = $state<{
agentName: string;
sceneCreated: boolean;
missionCount: number;
wasExisting: boolean;
warnings: readonly string[];
} | null>(null);
let error = $state<string | null>(null);
// Override toggles — default to the "smart" values we recommend.
let optCreateScene = $state(true);
let optCreateMissions = $state(true);
let optStartActive = $state(false); // false = respect paused hint
function openDetail(t: AgentTemplate) {
selected = t;
result = null;
error = null;
optCreateScene = t.scene !== undefined;
optCreateMissions = t.missions !== undefined && t.missions.length > 0;
optStartActive = false;
}
async function handleApply() {
if (!selected) return;
applying = true;
error = null;
try {
const r = await applyTemplate(selected, {
createScene: optCreateScene,
createMissions: optCreateMissions,
respectPauseHint: !optStartActive,
});
result = {
agentName: r.agent.name,
sceneCreated: r.sceneId !== undefined,
missionCount: r.missionIds.length,
wasExisting: r.wasExisting,
warnings: r.warnings,
};
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
applying = false;
}
}
</script>
<svelte:head>
<title>Agent-Templates — Mana</title>
</svelte:head>
<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>
<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.
</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}</span>
<span class="label">{t.label}</span>
<span class="tagline">{t.tagline}</span>
<span class="meta">
{#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}
</span>
</button>
{/each}
</div>
{#if selected}
<section class="detail" style="--accent: {selected.color}">
<header class="detail-head">
<span class="detail-avatar">{selected.agent.avatar}</span>
<div>
<h2>{selected.label}</h2>
<p class="detail-role">{selected.agent.role}</p>
</div>
</header>
<div class="detail-desc">{selected.description}</div>
{#if selected.scene}
<section class="preview">
<h3>Scene-Layout</h3>
<p class="preview-name">
<strong>{selected.scene.name}</strong>
{#if selected.scene.description}
{selected.scene.description}{/if}
</p>
<ul class="apps-preview">
{#each selected.scene.openApps as app (app.appId)}
<li>
<code>{app.appId}</code>
{#if app.widthPx}<span class="app-w">{app.widthPx}px</span>{/if}
</li>
{/each}
</ul>
</section>
{/if}
{#if selected.missions && selected.missions.length > 0}
<section class="preview">
<h3>Starter-Missionen</h3>
<ul class="missions-preview">
{#each selected.missions as m}
<li>
<strong>{m.title}</strong>
<p>{m.objective}</p>
<span class="cadence">
{#if m.cadence.kind === 'manual'}manuell auslösen
{:else if m.cadence.kind === 'daily'}täglich {m.cadence.atHour}:{String(
m.cadence.atMinute
).padStart(2, '0')}
{:else if m.cadence.kind === 'weekly'}wöchentlich, Tag {m.cadence.dayOfWeek} um {m
.cadence.atHour}:00
{:else if m.cadence.kind === 'interval'}alle {m.cadence.everyMinutes} Minuten
{:else}cron: {m.cadence.expression}
{/if}
</span>
</li>
{/each}
</ul>
</section>
{/if}
<section class="options">
<h3>Optionen</h3>
{#if selected.scene}
<label class="opt">
<input type="checkbox" bind:checked={optCreateScene} />
<span>Scene „{selected.scene.name}" anlegen und direkt öffnen</span>
</label>
{/if}
{#if selected.missions && selected.missions.length > 0}
<label class="opt">
<input type="checkbox" bind:checked={optCreateMissions} />
<span>Starter-Mission(en) mit anlegen</span>
</label>
<label class="opt" class:disabled={!optCreateMissions}>
<input type="checkbox" bind:checked={optStartActive} disabled={!optCreateMissions} />
<span>Mission(en) sofort aktivieren (Standard: pausiert)</span>
</label>
{/if}
</section>
{#if result}
<div class="result success">
<Check size={16} />
<div>
<strong>
{result.wasExisting
? `„${result.agentName}" existierte schon — wiederverwendet.`
: `Agent „${result.agentName}" angelegt.`}
</strong>
<p>
{#if result.sceneCreated}Scene angelegt + aktiviert.{/if}
{#if result.missionCount > 0}
{result.missionCount} Mission{result.missionCount !== 1 ? 'en' : ''}
{optStartActive ? 'aktiviert' : 'pausiert angelegt'}.
{/if}
</p>
{#if result.warnings.length > 0}
<ul class="warnings">
{#each result.warnings as w}
<li>{w}</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="result-actions">
<button type="button" class="btn-ghost" onclick={() => (selected = null)}>
Weiteres Template auswählen
</button>
<button type="button" class="btn-primary" onclick={() => goto('/')}>
Zum Workbench
</button>
</div>
{:else}
{#if error}
<div class="result error">
<strong>Konnte Template nicht anwenden</strong>
<p>{error}</p>
</div>
{/if}
<div class="apply-row">
<button type="button" class="btn-primary" onclick={handleApply} disabled={applying}>
<Play size={14} />
<span>{applying ? 'Lege an…' : `Template „${selected.label}" anwenden`}</span>
</button>
</div>
{/if}
</section>
{/if}
</div>
<style>
.page {
max-width: 1080px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back {
align-self: flex-start;
display: inline-flex;
gap: 0.375rem;
align-items: center;
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
h1 {
margin: 0;
font-size: 1.75rem;
}
.sub {
margin: 0;
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
max-width: 60ch;
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1.25rem;
border: 2px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
text-align: left;
cursor: pointer;
font: inherit;
transition: border-color 0.15s;
}
.card:hover {
border-color: var(--accent);
}
.card.selected {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 25%, transparent);
}
.avatar {
font-size: 2.5rem;
line-height: 1;
}
.label {
font-size: 1.125rem;
font-weight: 600;
}
.tagline {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
.meta {
margin-top: 0.5rem;
display: flex;
gap: 0.375rem;
}
.chip {
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: color-mix(in oklab, var(--accent) 15%, transparent);
color: var(--accent);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.detail {
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
padding: 1.5rem;
background: hsl(var(--color-surface));
display: flex;
flex-direction: column;
gap: 1rem;
border-top: 3px solid var(--accent);
}
.detail-head {
display: flex;
gap: 1rem;
align-items: center;
}
.detail-avatar {
font-size: 2.5rem;
line-height: 1;
}
.detail-head h2 {
margin: 0;
font-size: 1.25rem;
}
.detail-role {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.detail-desc {
white-space: pre-wrap;
font-size: 0.9375rem;
line-height: 1.55;
}
.preview {
padding: 0.875rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
}
.preview h3 {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.preview-name {
margin: 0 0 0.5rem;
font-size: 0.875rem;
}
.apps-preview {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.apps-preview li {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
font-size: 0.75rem;
display: inline-flex;
gap: 0.25rem;
align-items: center;
}
.apps-preview code {
font-size: 0.75rem;
}
.app-w {
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.missions-preview {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.missions-preview li strong {
font-size: 0.875rem;
}
.missions-preview li p {
margin: 0.125rem 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.cadence {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.options {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.options h3 {
margin: 0 0 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.opt {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.opt.disabled {
opacity: 0.5;
}
.apply-row {
display: flex;
justify-content: flex-end;
}
.btn-primary {
display: inline-flex;
gap: 0.375rem;
align-items: center;
padding: 0.625rem 1rem;
border: 1px solid var(--accent);
border-radius: 0.5rem;
background: var(--accent);
color: white;
cursor: pointer;
font: inherit;
font-size: 0.9375rem;
font-weight: 600;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-ghost {
padding: 0.625rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
cursor: pointer;
font: inherit;
font-size: 0.9375rem;
}
.result {
display: flex;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-radius: 0.5rem;
border: 1px solid;
}
.result.success {
border-color: #1b7a3a;
background: #d7f7e3;
color: #0f3f1d;
}
.result.error {
border-color: #8a1b1b;
background: #f7d7d7;
color: #3f0f0f;
}
.result p {
margin: 0.25rem 0 0;
font-size: 0.875rem;
}
.result strong {
font-size: 0.9375rem;
}
.warnings {
margin: 0.375rem 0 0;
padding-left: 1rem;
font-size: 0.8125rem;
}
.result-actions {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
</style>