mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(ai): AI features as top-level workbench apps (not sub-routes)
Reverts the previous /companion-carousel misstep. The user's model is
that Missions / Workbench / Rituals / Policy / Insights / Health each
live as their OWN app in the root `/` workbench scene, alongside todo /
calendar / notes / etc. — openable from the normal app picker, freely
combinable with any other module card.
- New modules, each with a ListView.svelte usable inside AppPage's
PageShell (no self-wrapping, no shell-control props):
`lib/modules/ai-missions/ListView.svelte`
`lib/modules/ai-workbench/ListView.svelte`
`lib/modules/ai-rituals/ListView.svelte`
`lib/modules/ai-policy/ListView.svelte`
`lib/modules/ai-insights/ListView.svelte`
`lib/modules/ai-health/ListView.svelte`
- Registered in `app-registry/apps.ts`: `ai-missions`, `ai-workbench`,
`ai-rituals`, `ai-policy`, `ai-insights`, `ai-health`. Each picks a
distinct color + icon.
- `/companion/+page.svelte` restored to the simple chat it was before.
The companion app (chat) remains as its own registered app —
unchanged.
- Removed: `lib/modules/companion/pages/` + the
`workbench-settings.svelte.ts` store (both were the dead-end
PageCarousel approach).
User model now:
` / ` (workbench root) → [+ App] → any of the 6 AI apps + any module
/companion → full-screen chat (unchanged)
/todo, /calendar, … → module-inline ghost inbox stays
svelte-check clean; webapp AI tests 71/71 green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9686198a16
commit
37e39a5ddb
14 changed files with 924 additions and 1729 deletions
|
|
@ -56,6 +56,9 @@ import {
|
|||
Pulse,
|
||||
Robot,
|
||||
Target,
|
||||
Flag,
|
||||
Notebook,
|
||||
Heartbeat,
|
||||
Smiley,
|
||||
Gear,
|
||||
Palette,
|
||||
|
|
@ -988,6 +991,71 @@ registerApp({
|
|||
},
|
||||
});
|
||||
|
||||
// ── AI Workbench apps — each feature is its own top-level app so it
|
||||
// can live alongside other modules in the user's scene. Colors loosely
|
||||
// group them: green=missions, amber=audit, pink=rituals, orange=policy,
|
||||
// violet=insights, emerald=health.
|
||||
|
||||
registerApp({
|
||||
id: 'ai-missions',
|
||||
name: 'AI Missions',
|
||||
color: '#22C55E',
|
||||
icon: Flag,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/ai-missions/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'ai-workbench',
|
||||
name: 'AI Workbench',
|
||||
color: '#F59E0B',
|
||||
icon: Notebook,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/ai-workbench/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'ai-rituals',
|
||||
name: 'AI Rituale',
|
||||
color: '#EC4899',
|
||||
icon: Lightning,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/ai-rituals/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'ai-policy',
|
||||
name: 'AI Policy',
|
||||
color: '#F97316',
|
||||
icon: Flag,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/ai-policy/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'ai-insights',
|
||||
name: 'AI Insights',
|
||||
color: '#8B5CF6',
|
||||
icon: Notebook,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/ai-insights/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'ai-health',
|
||||
name: 'AI Health',
|
||||
color: '#10B981',
|
||||
icon: Heartbeat,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/ai-health/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'goals',
|
||||
name: 'Ziele',
|
||||
|
|
|
|||
134
apps/mana/apps/web/src/lib/modules/ai-health/ListView.svelte
Normal file
134
apps/mana/apps/web/src/lib/modules/ai-health/ListView.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<!--
|
||||
AI Health app — runner status + manual tick + link to status page.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { isMissionTickRunning, productionDeps } from '$lib/data/ai/missions/setup';
|
||||
import { runDueMissions } from '$lib/data/ai/missions/runner';
|
||||
|
||||
let lastRunStats = $state<{ at: string; plansProduced: number; errors: number } | null>(null);
|
||||
let manualRunning = $state(false);
|
||||
|
||||
async function triggerManual() {
|
||||
manualRunning = true;
|
||||
try {
|
||||
const results = await runDueMissions(new Date(), productionDeps);
|
||||
let plansProduced = 0;
|
||||
let errors = 0;
|
||||
for (const r of results) {
|
||||
plansProduced += r.plannedSteps;
|
||||
errors += r.failedSteps;
|
||||
}
|
||||
lastRunStats = {
|
||||
at: new Date().toLocaleString('de-DE'),
|
||||
plansProduced,
|
||||
errors,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
lastRunStats = { at: new Date().toLocaleString('de-DE'), plansProduced: 0, errors: 1 };
|
||||
} finally {
|
||||
manualRunning = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h">
|
||||
<section>
|
||||
<h3>Foreground-Runner (dieser Tab)</h3>
|
||||
<dl>
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
{#if isMissionTickRunning()}
|
||||
<span class="ok">● aktiv · 60 s Interval</span>
|
||||
{:else}
|
||||
<span class="ko">● nicht aktiv</span>
|
||||
{/if}
|
||||
</dd>
|
||||
{#if lastRunStats}
|
||||
<dt>Letzter Manual-Tick</dt>
|
||||
<dd>
|
||||
{lastRunStats.at} · {lastRunStats.plansProduced} Plans · {lastRunStats.errors} Fehler
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
<button type="button" class="run" onclick={triggerManual} disabled={manualRunning}>
|
||||
{manualRunning ? 'Läuft…' : '⚡ Manual Tick'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Server-Runner (mana-ai)</h3>
|
||||
<p class="desc">
|
||||
Läuft unabhängig vom Browser. Status + Uptime werden von Prometheus gescrapet und auf dem
|
||||
Status-Dashboard angezeigt.
|
||||
</p>
|
||||
<a class="external" href="https://status.mana.how" target="_blank" rel="noreferrer">
|
||||
status.mana.how öffnen →
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Datenlage</h3>
|
||||
<p class="desc">
|
||||
Alles was hier angezeigt wird kommt lokal aus IndexedDB. Kein Server-Call außer beim Planner
|
||||
selbst.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.h {
|
||||
padding: 0.75rem 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
section h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
dt {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
.ok {
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.ko {
|
||||
color: #8a1b1b;
|
||||
}
|
||||
.run {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-surface));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.run:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.desc {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.45;
|
||||
}
|
||||
.external {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,38 +1,13 @@
|
|||
<!--
|
||||
Insights page — local aggregation over `_events` and mission
|
||||
iterations. No server calls; everything comes from Dexie liveQueries.
|
||||
AI Insights app — local aggregation over _events + mission iterations.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PageShell } from '$lib/components/page-carousel';
|
||||
import { COMPANION_PAGE_META } from './page-meta';
|
||||
import { useAiTimeline } from '$lib/data/ai/timeline/queries';
|
||||
import { useMissions } from '$lib/data/ai/missions/queries';
|
||||
|
||||
interface Props {
|
||||
widthPx: number;
|
||||
maximized?: boolean;
|
||||
onClose: () => void;
|
||||
onMaximize: () => void;
|
||||
onResize: (widthPx: number, heightPx?: number) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
widthPx,
|
||||
maximized = false,
|
||||
onClose,
|
||||
onMaximize,
|
||||
onResize,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}: Props = $props();
|
||||
|
||||
const meta = COMPANION_PAGE_META.insights;
|
||||
const events = $derived(useAiTimeline({ limit: 1000 }));
|
||||
const missions = $derived(useMissions());
|
||||
|
||||
// Count events per day across the last 14 days.
|
||||
const perDay = $derived.by(() => {
|
||||
const now = Date.now();
|
||||
const buckets: { day: string; count: number }[] = [];
|
||||
|
|
@ -49,7 +24,6 @@
|
|||
});
|
||||
const maxPerDay = $derived(Math.max(1, ...perDay.map((b) => b.count)));
|
||||
|
||||
// Aggregate iteration outcomes per mission.
|
||||
const missionStats = $derived.by(() =>
|
||||
missions.value.map((m) => {
|
||||
let approved = 0;
|
||||
|
|
@ -62,25 +36,15 @@
|
|||
else if (it.overallStatus === 'failed') failed++;
|
||||
else if (it.overallStatus === 'awaiting-review') awaiting++;
|
||||
}
|
||||
return {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
approved,
|
||||
rejected,
|
||||
failed,
|
||||
awaiting,
|
||||
total: m.iterations.length,
|
||||
};
|
||||
return { id: m.id, title: m.title, approved, rejected, failed, awaiting };
|
||||
})
|
||||
);
|
||||
|
||||
// Top recurring feedback strings from iteration.userFeedback.
|
||||
const topFeedback = $derived.by(() => {
|
||||
const freq = new Map<string, number>();
|
||||
for (const m of missions.value) {
|
||||
for (const it of m.iterations) {
|
||||
if (!it.userFeedback) continue;
|
||||
// Normalize: lowercase + trim. Short strings, no ML tokenizer.
|
||||
const key = it.userFeedback.trim().toLowerCase().slice(0, 80);
|
||||
freq.set(key, (freq.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
|
@ -91,8 +55,6 @@
|
|||
.map(([text, count]) => ({ text, count }));
|
||||
});
|
||||
|
||||
// Simple approval rate: approved / (approved + rejected), ignoring
|
||||
// awaiting/failed.
|
||||
const approvalRate = $derived.by(() => {
|
||||
let a = 0;
|
||||
let r = 0;
|
||||
|
|
@ -105,91 +67,75 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<PageShell
|
||||
{widthPx}
|
||||
{maximized}
|
||||
{onClose}
|
||||
{onMaximize}
|
||||
{onResize}
|
||||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
title={meta.title}
|
||||
color={meta.color}
|
||||
icon={meta.icon}
|
||||
>
|
||||
<div class="ins">
|
||||
<section>
|
||||
<h3>Approval-Rate</h3>
|
||||
{#if approvalRate === null}
|
||||
<p class="empty">Noch nicht genug Daten.</p>
|
||||
{:else}
|
||||
<div class="big">{approvalRate}%</div>
|
||||
<p class="sub">über alle Missions + alle Iterationen</p>
|
||||
{/if}
|
||||
</section>
|
||||
<div class="ins">
|
||||
<section>
|
||||
<h3>Approval-Rate</h3>
|
||||
{#if approvalRate === null}
|
||||
<p class="empty">Noch nicht genug Daten.</p>
|
||||
{:else}
|
||||
<div class="big">{approvalRate}%</div>
|
||||
<p class="sub">über alle Missions + alle Iterationen</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>AI-Events / Tag (14 Tage)</h3>
|
||||
<div class="chart">
|
||||
{#each perDay as b (b.day)}
|
||||
<div class="bar" title={`${b.day}: ${b.count}`}>
|
||||
<span
|
||||
class="fill"
|
||||
style="height: {(b.count / maxPerDay) * 100}%;"
|
||||
class:has-value={b.count > 0}
|
||||
></span>
|
||||
</div>
|
||||
<section>
|
||||
<h3>AI-Events / Tag (14 Tage)</h3>
|
||||
<div class="chart">
|
||||
{#each perDay as b (b.day)}
|
||||
<div class="bar" title={`${b.day}: ${b.count}`}>
|
||||
<span
|
||||
class="fill"
|
||||
style="height: {(b.count / maxPerDay) * 100}%;"
|
||||
class:has-value={b.count > 0}
|
||||
></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Pro Mission</h3>
|
||||
{#if missionStats.length === 0}
|
||||
<p class="empty">Keine Missions angelegt.</p>
|
||||
{:else}
|
||||
<ul class="m-stats">
|
||||
{#each missionStats as m (m.id)}
|
||||
<li>
|
||||
<span class="m-title">{m.title}</span>
|
||||
<span class="m-nums">
|
||||
<span class="n ok">{m.approved}</span>·
|
||||
<span class="n ko">{m.rejected}</span>·
|
||||
<span class="n wait">{m.awaiting}</span>·
|
||||
<span class="n err">{m.failed}</span>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</ul>
|
||||
<p class="legend">
|
||||
<span class="n ok">●</span>approved ·
|
||||
<span class="n ko">●</span>rejected ·
|
||||
<span class="n wait">●</span>awaiting ·
|
||||
<span class="n err">●</span>failed
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Pro Mission</h3>
|
||||
{#if missionStats.length === 0}
|
||||
<p class="empty">Keine Missions angelegt.</p>
|
||||
{:else}
|
||||
<ul class="m-stats">
|
||||
{#each missionStats as m (m.id)}
|
||||
<li>
|
||||
<span class="m-title">{m.title}</span>
|
||||
<span class="m-nums">
|
||||
<span class="n ok">{m.approved}</span>
|
||||
·
|
||||
<span class="n ko">{m.rejected}</span>
|
||||
·
|
||||
<span class="n wait">{m.awaiting}</span>
|
||||
·
|
||||
<span class="n err">{m.failed}</span>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="legend">
|
||||
<span class="n ok">●</span>approved ·
|
||||
<span class="n ko">●</span>rejected ·
|
||||
<span class="n wait">●</span>awaiting ·
|
||||
<span class="n err">●</span>failed
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Häufigstes Feedback</h3>
|
||||
{#if topFeedback.length === 0}
|
||||
<p class="empty">Noch keine Freitext-Reviews.</p>
|
||||
{:else}
|
||||
<ul class="fb-list">
|
||||
{#each topFeedback as fb}
|
||||
<li>
|
||||
<span class="fb-count">{fb.count}×</span>
|
||||
<span class="fb-text">"{fb.text}"</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</PageShell>
|
||||
<section>
|
||||
<h3>Häufigstes Feedback</h3>
|
||||
{#if topFeedback.length === 0}
|
||||
<p class="empty">Noch keine Freitext-Reviews.</p>
|
||||
{:else}
|
||||
<ul class="fb-list">
|
||||
{#each topFeedback as fb}
|
||||
<li>
|
||||
<span class="fb-count">{fb.count}×</span>
|
||||
<span class="fb-text">"{fb.text}"</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ins {
|
||||
|
|
@ -269,6 +215,7 @@
|
|||
}
|
||||
.n {
|
||||
font-weight: 600;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
.n.ok {
|
||||
color: #1b7a3a;
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
<!--
|
||||
Missions page — list ↔ detail in a single panel. Replaces the former
|
||||
/companion/missions sub-route with a master-detail-inline view.
|
||||
AI Missions app — workbench card.
|
||||
Renders inside AppPage, which provides the PageShell + window chrome.
|
||||
Master-detail inline (list ↔ create ↔ detail) in a single panel.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PageShell } from '$lib/components/page-carousel';
|
||||
import { ArrowLeft, Play, Pause, Check, Trash, Plus } from '@mana/shared-icons';
|
||||
import { COMPANION_PAGE_META } from './page-meta';
|
||||
import { useMissions } from '$lib/data/ai/missions/queries';
|
||||
import {
|
||||
createMission,
|
||||
|
|
@ -20,27 +19,6 @@
|
|||
import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte';
|
||||
import type { Mission, MissionCadence, MissionInputRef } from '$lib/data/ai/missions/types';
|
||||
|
||||
interface Props {
|
||||
widthPx: number;
|
||||
maximized?: boolean;
|
||||
onClose: () => void;
|
||||
onMaximize: () => void;
|
||||
onResize: (widthPx: number, heightPx?: number) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
widthPx,
|
||||
maximized = false,
|
||||
onClose,
|
||||
onMaximize,
|
||||
onResize,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}: Props = $props();
|
||||
|
||||
const meta = COMPANION_PAGE_META.missions;
|
||||
const missions = $derived(useMissions());
|
||||
|
||||
let mode = $state<'list' | 'create' | 'detail'>('list');
|
||||
|
|
@ -49,7 +27,6 @@
|
|||
selectedId ? (missions.value.find((m) => m.id === selectedId) ?? null) : null
|
||||
);
|
||||
|
||||
// ── Create form ────────────────────────────────────────
|
||||
let formTitle = $state('');
|
||||
let formObjective = $state('');
|
||||
let formConcept = $state('');
|
||||
|
|
@ -97,7 +74,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ── Detail actions ─────────────────────────────────────
|
||||
let runningNow = $state(false);
|
||||
async function handleRunNow(m: Mission) {
|
||||
runningNow = true;
|
||||
|
|
@ -131,13 +107,11 @@
|
|||
return `cron: ${c.expression}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describeState(s: Mission['state']): string {
|
||||
return { active: 'aktiv', paused: 'pausiert', done: 'abgeschlossen', archived: 'archiviert' }[
|
||||
s
|
||||
];
|
||||
}
|
||||
|
||||
function formatRelative(iso: string | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
|
|
@ -155,209 +129,203 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<PageShell
|
||||
{widthPx}
|
||||
{maximized}
|
||||
{onClose}
|
||||
{onMaximize}
|
||||
{onResize}
|
||||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
title={meta.title}
|
||||
color={meta.color}
|
||||
icon={meta.icon}
|
||||
>
|
||||
{#if mode === 'list'}
|
||||
<div class="list-pane">
|
||||
<header class="pane-header">
|
||||
<button type="button" class="primary" onclick={() => (mode = 'create')}>
|
||||
<Plus size={14} /><span>Neue Mission</span>
|
||||
</button>
|
||||
</header>
|
||||
{#if missions.value.length === 0}
|
||||
<p class="empty">
|
||||
Keine Missions — lege eine an um die KI dauerhaft für dich arbeiten zu lassen.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="m-list">
|
||||
{#each missions.value as m (m.id)}
|
||||
<li>
|
||||
<button type="button" class="m-item" onclick={() => openDetail(m.id)}>
|
||||
<span class="m-title">
|
||||
<span class="dot dot-{m.state}"></span>
|
||||
{m.title}
|
||||
</span>
|
||||
<span class="m-meta">
|
||||
<span>{describeCadence(m.cadence)}</span>
|
||||
<span>{m.iterations.length} Iter.</span>
|
||||
{#if m.nextRunAt}
|
||||
<span>next {formatRelative(m.nextRunAt)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if mode === 'create'}
|
||||
<form class="create" onsubmit={(e) => (e.preventDefault(), handleCreate())}>
|
||||
<button type="button" class="back-btn" onclick={() => (mode = 'list')}>
|
||||
<ArrowLeft size={14} /><span>Abbrechen</span>
|
||||
{#if mode === 'list'}
|
||||
<div class="pane">
|
||||
<header class="bar">
|
||||
<button type="button" class="primary" onclick={() => (mode = 'create')}>
|
||||
<Plus size={14} /><span>Neue Mission</span>
|
||||
</button>
|
||||
<label>
|
||||
<span class="lbl">Titel</span>
|
||||
<input bind:value={formTitle} placeholder="z.B. Wöchentlicher Review" required />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Konkretes Ziel</span>
|
||||
<input bind:value={formObjective} placeholder="Was soll die KI erreichen?" required />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Konzept (Markdown, optional)</span>
|
||||
<textarea
|
||||
bind:value={formConcept}
|
||||
placeholder="# Rahmen Erkläre der KI Kontext, Regeln, Grenzen…"
|
||||
rows="5"
|
||||
></textarea>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Inputs (Kontext für die KI)</legend>
|
||||
<MissionInputPicker bind:value={formInputs} />
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Cadence</legend>
|
||||
<div class="cadence-row">
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="manual" /> Manuell
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="interval" /> Intervall
|
||||
{#if formCadenceKind === 'interval'}
|
||||
<input
|
||||
class="inline-num"
|
||||
type="number"
|
||||
bind:value={formIntervalMin}
|
||||
min="5"
|
||||
max="1440"
|
||||
/>
|
||||
<span>min</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="daily" /> Täglich um
|
||||
{#if formCadenceKind === 'daily'}
|
||||
<input class="inline-num" type="number" bind:value={formDailyHour} min="0" max="23" />
|
||||
:00
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary" disabled={creating}>
|
||||
{creating ? 'Erstelle…' : 'Mission anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if selected}
|
||||
<div class="detail">
|
||||
<button type="button" class="back-btn" onclick={() => (mode = 'list')}>
|
||||
<ArrowLeft size={14} /><span>Liste</span>
|
||||
</button>
|
||||
<h2 class="detail-title">{selected.title}</h2>
|
||||
<div class="detail-actions">
|
||||
<button type="button" onclick={() => handleRunNow(selected)} disabled={runningNow}>
|
||||
<Play size={12} /><span>{runningNow ? 'Läuft…' : 'Jetzt ausführen'}</span>
|
||||
</button>
|
||||
{#if selected.state === 'active'}
|
||||
<button type="button" onclick={() => pauseMission(selected.id)}>
|
||||
<Pause size={12} /><span>Pause</span>
|
||||
</button>
|
||||
{:else if selected.state === 'paused'}
|
||||
<button type="button" onclick={() => resumeMission(selected.id)}>
|
||||
<Play size={12} /><span>Fortsetzen</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if selected.state !== 'done'}
|
||||
<button type="button" onclick={() => completeMission(selected.id)}>
|
||||
<Check size={12} /><span>Abschließen</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
onclick={() => {
|
||||
if (confirm('Mission löschen?')) {
|
||||
deleteMission(selected.id);
|
||||
mode = 'list';
|
||||
selectedId = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="meta">
|
||||
<dt>Ziel</dt>
|
||||
<dd>{selected.objective}</dd>
|
||||
<dt>Cadence</dt>
|
||||
<dd>{describeCadence(selected.cadence)} · {describeState(selected.state)}</dd>
|
||||
<dt>Next</dt>
|
||||
<dd>{formatRelative(selected.nextRunAt)}</dd>
|
||||
<dt>Inputs</dt>
|
||||
<dd>
|
||||
{#if selected.inputs.length === 0}—{:else}
|
||||
{selected.inputs.map((i) => `${i.module}/${i.id}`).join(', ')}
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{#if selected.conceptMarkdown}
|
||||
<details>
|
||||
<summary>Konzept</summary>
|
||||
<pre>{selected.conceptMarkdown}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<h3 class="section-title">Iterationen</h3>
|
||||
{#if selected.iterations.length === 0}
|
||||
<p class="empty">Noch keine Iteration gelaufen.</p>
|
||||
{:else}
|
||||
{#each [...selected.iterations].reverse() as it (it.id)}
|
||||
<article class="it">
|
||||
<header>
|
||||
<span class="it-date">{new Date(it.startedAt).toLocaleString('de-DE')}</span>
|
||||
<span class="badge badge-{it.overallStatus}">{it.overallStatus}</span>
|
||||
</header>
|
||||
{#if it.summary}<p class="it-summary">{it.summary}</p>{/if}
|
||||
{#if it.userFeedback}
|
||||
<blockquote class="fb">{it.userFeedback}</blockquote>
|
||||
{:else if it.overallStatus === 'awaiting-review'}
|
||||
<form
|
||||
class="fb-form"
|
||||
onsubmit={(e) => (e.preventDefault(), handleFeedback(selected, it.id))}
|
||||
>
|
||||
<textarea
|
||||
bind:value={feedbackDraft}
|
||||
placeholder="Feedback für die nächste Iteration…"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" disabled={!feedbackDraft.trim()}>Speichern</button>
|
||||
</form>
|
||||
{/if}
|
||||
</article>
|
||||
</header>
|
||||
{#if missions.value.length === 0}
|
||||
<p class="empty">
|
||||
Keine Missions — lege eine an um die KI dauerhaft für dich arbeiten zu lassen.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="m-list">
|
||||
{#each missions.value as m (m.id)}
|
||||
<li>
|
||||
<button type="button" class="m-item" onclick={() => openDetail(m.id)}>
|
||||
<span class="m-title">
|
||||
<span class="dot dot-{m.state}"></span>
|
||||
{m.title}
|
||||
</span>
|
||||
<span class="m-meta">
|
||||
<span>{describeCadence(m.cadence)}</span>
|
||||
<span>{m.iterations.length} Iter.</span>
|
||||
{#if m.nextRunAt}
|
||||
<span>next {formatRelative(m.nextRunAt)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if mode === 'create'}
|
||||
<form class="create" onsubmit={(e) => (e.preventDefault(), handleCreate())}>
|
||||
<button type="button" class="back-btn" onclick={() => (mode = 'list')}>
|
||||
<ArrowLeft size={14} /><span>Abbrechen</span>
|
||||
</button>
|
||||
<label>
|
||||
<span class="lbl">Titel</span>
|
||||
<input bind:value={formTitle} placeholder="z.B. Wöchentlicher Review" required />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Konkretes Ziel</span>
|
||||
<input bind:value={formObjective} placeholder="Was soll die KI erreichen?" required />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Konzept (Markdown, optional)</span>
|
||||
<textarea
|
||||
bind:value={formConcept}
|
||||
placeholder="# Rahmen Erkläre der KI Kontext, Regeln, Grenzen…"
|
||||
rows="5"
|
||||
></textarea>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Inputs (Kontext für die KI)</legend>
|
||||
<MissionInputPicker bind:value={formInputs} />
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Cadence</legend>
|
||||
<div class="cadence-row">
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="manual" /> Manuell
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="interval" /> Intervall
|
||||
{#if formCadenceKind === 'interval'}
|
||||
<input
|
||||
class="inline-num"
|
||||
type="number"
|
||||
bind:value={formIntervalMin}
|
||||
min="5"
|
||||
max="1440"
|
||||
/>
|
||||
<span>min</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="daily" /> Täglich um
|
||||
{#if formCadenceKind === 'daily'}
|
||||
<input class="inline-num" type="number" bind:value={formDailyHour} min="0" max="23" />
|
||||
:00
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary" disabled={creating}>
|
||||
{creating ? 'Erstelle…' : 'Mission anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</PageShell>
|
||||
</form>
|
||||
{:else if selected}
|
||||
<div class="detail">
|
||||
<button type="button" class="back-btn" onclick={() => (mode = 'list')}>
|
||||
<ArrowLeft size={14} /><span>Liste</span>
|
||||
</button>
|
||||
<h2 class="detail-title">{selected.title}</h2>
|
||||
<div class="detail-actions">
|
||||
<button type="button" onclick={() => handleRunNow(selected)} disabled={runningNow}>
|
||||
<Play size={12} /><span>{runningNow ? 'Läuft…' : 'Jetzt ausführen'}</span>
|
||||
</button>
|
||||
{#if selected.state === 'active'}
|
||||
<button type="button" onclick={() => pauseMission(selected.id)}>
|
||||
<Pause size={12} /><span>Pause</span>
|
||||
</button>
|
||||
{:else if selected.state === 'paused'}
|
||||
<button type="button" onclick={() => resumeMission(selected.id)}>
|
||||
<Play size={12} /><span>Fortsetzen</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if selected.state !== 'done'}
|
||||
<button type="button" onclick={() => completeMission(selected.id)}>
|
||||
<Check size={12} /><span>Abschließen</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
onclick={() => {
|
||||
if (confirm('Mission löschen?')) {
|
||||
deleteMission(selected.id);
|
||||
mode = 'list';
|
||||
selectedId = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="meta">
|
||||
<dt>Ziel</dt>
|
||||
<dd>{selected.objective}</dd>
|
||||
<dt>Cadence</dt>
|
||||
<dd>{describeCadence(selected.cadence)} · {describeState(selected.state)}</dd>
|
||||
<dt>Next</dt>
|
||||
<dd>{formatRelative(selected.nextRunAt)}</dd>
|
||||
<dt>Inputs</dt>
|
||||
<dd>
|
||||
{#if selected.inputs.length === 0}—{:else}
|
||||
{selected.inputs.map((i) => `${i.module}/${i.id}`).join(', ')}
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{#if selected.conceptMarkdown}
|
||||
<details>
|
||||
<summary>Konzept</summary>
|
||||
<pre>{selected.conceptMarkdown}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<h3 class="section-title">Iterationen</h3>
|
||||
{#if selected.iterations.length === 0}
|
||||
<p class="empty">Noch keine Iteration gelaufen.</p>
|
||||
{:else}
|
||||
{#each [...selected.iterations].reverse() as it (it.id)}
|
||||
<article class="it">
|
||||
<header>
|
||||
<span class="it-date">{new Date(it.startedAt).toLocaleString('de-DE')}</span>
|
||||
<span class="badge badge-{it.overallStatus}">{it.overallStatus}</span>
|
||||
</header>
|
||||
{#if it.summary}<p class="it-summary">{it.summary}</p>{/if}
|
||||
{#if it.userFeedback}
|
||||
<blockquote class="fb">{it.userFeedback}</blockquote>
|
||||
{:else if it.overallStatus === 'awaiting-review'}
|
||||
<form
|
||||
class="fb-form"
|
||||
onsubmit={(e) => (e.preventDefault(), handleFeedback(selected, it.id))}
|
||||
>
|
||||
<textarea
|
||||
bind:value={feedbackDraft}
|
||||
placeholder="Feedback für die nächste Iteration…"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" disabled={!feedbackDraft.trim()}>Speichern</button>
|
||||
</form>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pane-header {
|
||||
.pane,
|
||||
.create,
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem 1.5rem;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.primary {
|
||||
display: inline-flex;
|
||||
|
|
@ -374,7 +342,6 @@
|
|||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.empty {
|
||||
padding: 1.5rem 1rem;
|
||||
|
|
@ -382,15 +349,10 @@
|
|||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
.list-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.m-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.75rem 1rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
|
|
@ -423,7 +385,6 @@
|
|||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-active {
|
||||
background: #22c55e;
|
||||
|
|
@ -443,14 +404,6 @@
|
|||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.create,
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem 1.5rem;
|
||||
}
|
||||
.back-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
|
|
@ -476,7 +429,6 @@
|
|||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.create input[type='text'],
|
||||
.create input:not([type]),
|
||||
.create textarea {
|
||||
padding: 0.375rem 0.5rem;
|
||||
|
|
@ -510,7 +462,6 @@
|
|||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
|
|
@ -1,42 +1,12 @@
|
|||
<!--
|
||||
Policy page — lets the user override per-tool AI policy decisions
|
||||
(auto / propose / deny). Overrides stored in localStorage and merged
|
||||
into `getAiPolicy()` on the fly via `setAiPolicy`.
|
||||
|
||||
Note: the active tool registry (`getTools()`) only contains tools
|
||||
that modules have registered so far in the current session. For the
|
||||
editor to show every tool on first load, the tool-registration
|
||||
modules need to have been initialized — which they are, via
|
||||
`initTools()` in (app)/+layout.svelte.
|
||||
AI Policy app — per-tool 3-way toggle (auto / propose / deny).
|
||||
Overrides stored in localStorage, merged into DEFAULT_AI_POLICY.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PageShell } from '$lib/components/page-carousel';
|
||||
import { COMPANION_PAGE_META } from './page-meta';
|
||||
import { getTools } from '$lib/data/tools/registry';
|
||||
import { DEFAULT_AI_POLICY, setAiPolicy, getAiPolicy } from '$lib/data/ai/policy';
|
||||
import type { PolicyDecision, AiPolicy } from '$lib/data/ai/policy';
|
||||
|
||||
interface Props {
|
||||
widthPx: number;
|
||||
maximized?: boolean;
|
||||
onClose: () => void;
|
||||
onMaximize: () => void;
|
||||
onResize: (widthPx: number, heightPx?: number) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
widthPx,
|
||||
maximized = false,
|
||||
onClose,
|
||||
onMaximize,
|
||||
onResize,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}: Props = $props();
|
||||
|
||||
const meta = COMPANION_PAGE_META.policy;
|
||||
const STORAGE_KEY = 'ai:policyOverrides';
|
||||
|
||||
function loadOverrides(): Record<string, PolicyDecision> {
|
||||
|
|
@ -49,7 +19,6 @@
|
|||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveOverrides(overrides: Record<string, PolicyDecision>): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
|
|
@ -58,7 +27,6 @@
|
|||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function applyPolicy(overrides: Record<string, PolicyDecision>): void {
|
||||
const nextPolicy: AiPolicy = {
|
||||
...DEFAULT_AI_POLICY,
|
||||
|
|
@ -88,7 +56,6 @@
|
|||
if (overrides[toolName]) return overrides[toolName];
|
||||
return getAiPolicy().tools[toolName] ?? getAiPolicy().defaultForAi;
|
||||
}
|
||||
|
||||
function setDecision(toolName: string, d: PolicyDecision): void {
|
||||
const dflt = DEFAULT_AI_POLICY.tools[toolName] ?? DEFAULT_AI_POLICY.defaultForAi;
|
||||
if (d === dflt) {
|
||||
|
|
@ -98,75 +65,59 @@
|
|||
overrides = { ...overrides, [toolName]: d };
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (!confirm('Alle Policy-Überschreibungen zurücksetzen?')) return;
|
||||
overrides = {};
|
||||
}
|
||||
|
||||
const hasOverrides = $derived(Object.keys(overrides).length > 0);
|
||||
</script>
|
||||
|
||||
<PageShell
|
||||
{widthPx}
|
||||
{maximized}
|
||||
{onClose}
|
||||
{onMaximize}
|
||||
{onResize}
|
||||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
title={meta.title}
|
||||
color={meta.color}
|
||||
icon={meta.icon}
|
||||
>
|
||||
<div class="policy">
|
||||
<header class="info">
|
||||
<p>
|
||||
Pro Tool festlegen was passiert wenn die KI es aufruft.
|
||||
<strong>auto</strong> führt sofort aus,
|
||||
<strong>propose</strong> stagt als Vorschlag zur Freigabe,
|
||||
<strong>deny</strong> sperrt das Tool komplett.
|
||||
</p>
|
||||
{#if hasOverrides}
|
||||
<button type="button" class="reset" onclick={resetAll}>
|
||||
Zurücksetzen ({Object.keys(overrides).length})
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
<div class="policy">
|
||||
<header class="info">
|
||||
<p>
|
||||
Pro Tool festlegen was passiert wenn die KI es aufruft.
|
||||
<strong>auto</strong> führt sofort aus, <strong>propose</strong> stagt als Vorschlag,
|
||||
<strong>deny</strong> sperrt das Tool komplett.
|
||||
</p>
|
||||
{#if hasOverrides}
|
||||
<button type="button" class="reset" onclick={resetAll}>
|
||||
Zurücksetzen ({Object.keys(overrides).length})
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#each grouped as [mod, list] (mod)}
|
||||
<section>
|
||||
<h3>{mod}</h3>
|
||||
<ul>
|
||||
{#each list as t (t.name)}
|
||||
{@const current = decide(t.name)}
|
||||
{@const overridden = overrides[t.name] !== undefined}
|
||||
<li class:overridden>
|
||||
<div class="t-info">
|
||||
<span class="t-name">{t.name}</span>
|
||||
<span class="t-desc">{t.description}</span>
|
||||
</div>
|
||||
<div class="t-picker" role="radiogroup" aria-label={t.name}>
|
||||
{#each ['auto', 'propose', 'deny'] as PolicyDecision[] as d}
|
||||
<button
|
||||
type="button"
|
||||
class="pill pill-{d}"
|
||||
class:active={current === d}
|
||||
onclick={() => setDecision(t.name, d)}
|
||||
aria-checked={current === d}
|
||||
role="radio"
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</PageShell>
|
||||
{#each grouped as [mod, list] (mod)}
|
||||
<section>
|
||||
<h3>{mod}</h3>
|
||||
<ul>
|
||||
{#each list as t (t.name)}
|
||||
{@const current = decide(t.name)}
|
||||
{@const overridden = overrides[t.name] !== undefined}
|
||||
<li class:overridden>
|
||||
<div class="t-info">
|
||||
<span class="t-name">{t.name}</span>
|
||||
<span class="t-desc">{t.description}</span>
|
||||
</div>
|
||||
<div class="t-picker" role="radiogroup" aria-label={t.name}>
|
||||
{#each ['auto', 'propose', 'deny'] as PolicyDecision[] as d}
|
||||
<button
|
||||
type="button"
|
||||
class="pill pill-{d}"
|
||||
class:active={current === d}
|
||||
onclick={() => setDecision(t.name, d)}
|
||||
aria-checked={current === d}
|
||||
role="radio"
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.policy {
|
||||
|
|
@ -1,36 +1,12 @@
|
|||
<!--
|
||||
Rituals page — guided routines. Wraps the existing RitualRunner
|
||||
component from the companion module.
|
||||
AI Rituals app — guided routines. Wraps the existing RitualRunner.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PageShell } from '$lib/components/page-carousel';
|
||||
import { Plus, Play, Trash } from '@mana/shared-icons';
|
||||
import RitualRunner from '$lib/modules/companion/components/RitualRunner.svelte';
|
||||
import { ritualStore, useAllRituals, RITUAL_TEMPLATES } from '$lib/companion/rituals';
|
||||
import type { LocalRitual } from '$lib/companion/rituals/types';
|
||||
import { COMPANION_PAGE_META } from './page-meta';
|
||||
|
||||
interface Props {
|
||||
widthPx: number;
|
||||
maximized?: boolean;
|
||||
onClose: () => void;
|
||||
onMaximize: () => void;
|
||||
onResize: (widthPx: number, heightPx?: number) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
widthPx,
|
||||
maximized = false,
|
||||
onClose,
|
||||
onMaximize,
|
||||
onResize,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}: Props = $props();
|
||||
|
||||
const meta = COMPANION_PAGE_META.rituals;
|
||||
const rituals = useAllRituals();
|
||||
let activeRitual = $state<LocalRitual | null>(null);
|
||||
let showTemplates = $state(false);
|
||||
|
|
@ -43,68 +19,55 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<PageShell
|
||||
{widthPx}
|
||||
{maximized}
|
||||
{onClose}
|
||||
{onMaximize}
|
||||
{onResize}
|
||||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
title={meta.title}
|
||||
color={meta.color}
|
||||
icon={meta.icon}
|
||||
>
|
||||
<div class="r">
|
||||
{#if activeRitual}
|
||||
<button class="back" onclick={() => (activeRitual = null)}>← Zurück</button>
|
||||
<RitualRunner
|
||||
ritual={activeRitual}
|
||||
onComplete={() => (activeRitual = null)}
|
||||
onClose={() => (activeRitual = null)}
|
||||
/>
|
||||
{:else}
|
||||
<header class="bar">
|
||||
<button type="button" class="primary" onclick={() => (showTemplates = !showTemplates)}>
|
||||
<Plus size={14} /><span>Aus Template</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="r">
|
||||
{#if activeRitual}
|
||||
<button class="back" onclick={() => (activeRitual = null)}>← Zurück</button>
|
||||
<RitualRunner
|
||||
ritual={activeRitual}
|
||||
onComplete={() => (activeRitual = null)}
|
||||
onClose={() => (activeRitual = null)}
|
||||
/>
|
||||
{:else}
|
||||
<header class="bar">
|
||||
<button type="button" class="primary" onclick={() => (showTemplates = !showTemplates)}>
|
||||
<Plus size={14} /><span>Aus Template</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showTemplates}
|
||||
<div class="templates">
|
||||
{#each RITUAL_TEMPLATES as t}
|
||||
<button type="button" class="template" onclick={() => createFromTemplate(t.id)}>
|
||||
<strong>{t.title}</strong>
|
||||
<span>{t.description ?? ''}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="list">
|
||||
{#each rituals.value as r (r.id)}
|
||||
<li class="item">
|
||||
<button type="button" class="item-main" onclick={() => (activeRitual = r)}>
|
||||
<Play size={12} />
|
||||
<span>{r.title}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="item-del"
|
||||
onclick={() => ritualStore.delete(r.id)}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash size={11} />
|
||||
</button>
|
||||
</li>
|
||||
{#if showTemplates}
|
||||
<div class="templates">
|
||||
{#each RITUAL_TEMPLATES as t}
|
||||
<button type="button" class="template" onclick={() => createFromTemplate(t.id)}>
|
||||
<strong>{t.title}</strong>
|
||||
<span>{t.description ?? ''}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if rituals.value.length === 0 && !showTemplates}
|
||||
<li class="empty">Noch keine Rituale — erstelle eines aus einer Vorlage oben.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<ul class="list">
|
||||
{#each rituals.value as r (r.id)}
|
||||
<li class="item">
|
||||
<button type="button" class="item-main" onclick={() => (activeRitual = r)}>
|
||||
<Play size={12} />
|
||||
<span>{r.title}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="item-del"
|
||||
onclick={() => ritualStore.delete(r.id)}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash size={11} />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#if rituals.value.length === 0 && !showTemplates}
|
||||
<li class="empty">Noch keine Rituale — erstelle eines aus einer Vorlage oben.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.r {
|
||||
|
|
@ -1,38 +1,14 @@
|
|||
<!--
|
||||
Workbench page — AI activity timeline grouped by mission iteration,
|
||||
with per-bucket Revert button. Replaces /companion/workbench.
|
||||
AI Workbench app — timeline of AI activity, grouped per mission
|
||||
iteration. Per-bucket Revert button.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PageShell } from '$lib/components/page-carousel';
|
||||
import { ArrowSquareOut, ArrowCounterClockwise } from '@mana/shared-icons';
|
||||
import { COMPANION_PAGE_META } from './page-meta';
|
||||
import { useAiTimeline, bucketByIteration } from '$lib/data/ai/timeline/queries';
|
||||
import { useMissions } from '$lib/data/ai/missions/queries';
|
||||
import { revertIteration } from '$lib/data/ai/revert/revert-iteration';
|
||||
import type { DomainEvent } from '$lib/data/events/types';
|
||||
|
||||
interface Props {
|
||||
widthPx: number;
|
||||
maximized?: boolean;
|
||||
onClose: () => void;
|
||||
onMaximize: () => void;
|
||||
onResize: (widthPx: number, heightPx?: number) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
widthPx,
|
||||
maximized = false,
|
||||
onClose,
|
||||
onMaximize,
|
||||
onResize,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}: Props = $props();
|
||||
|
||||
const meta = COMPANION_PAGE_META.workbench;
|
||||
|
||||
let moduleFilter = $state<string | null>(null);
|
||||
let missionFilter = $state<string | null>(null);
|
||||
|
||||
|
|
@ -56,7 +32,6 @@
|
|||
: undefined;
|
||||
return title ? `${e.type} · ${title}` : e.type;
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
|
@ -65,9 +40,9 @@
|
|||
}
|
||||
|
||||
let revertingKey = $state<string | null>(null);
|
||||
async function handleRevert(bucketKey: string, missionId: string, iterationId: string) {
|
||||
async function handleRevert(key: string, missionId: string, iterationId: string) {
|
||||
if (!confirm('Alle AI-Writes dieser Iteration zurücknehmen?')) return;
|
||||
revertingKey = bucketKey;
|
||||
revertingKey = key;
|
||||
try {
|
||||
const stats = await revertIteration(missionId, iterationId);
|
||||
const parts = [`${stats.reverted} zurückgenommen`];
|
||||
|
|
@ -75,7 +50,7 @@
|
|||
if (stats.failed > 0) parts.push(`${stats.failed} fehlgeschlagen`);
|
||||
alert(parts.join(' · '));
|
||||
} catch (err) {
|
||||
console.error('[workbench] revert failed:', err);
|
||||
console.error(err);
|
||||
alert('Revert fehlgeschlagen — siehe Console.');
|
||||
} finally {
|
||||
revertingKey = null;
|
||||
|
|
@ -83,89 +58,76 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<PageShell
|
||||
{widthPx}
|
||||
{maximized}
|
||||
{onClose}
|
||||
{onMaximize}
|
||||
{onResize}
|
||||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
title={meta.title}
|
||||
color={meta.color}
|
||||
icon={meta.icon}
|
||||
>
|
||||
<div class="wb">
|
||||
<div class="filters">
|
||||
<label>
|
||||
<span class="lbl">Modul</span>
|
||||
<select bind:value={moduleFilter}>
|
||||
<option value={null}>alle</option>
|
||||
{#each allModules as m}
|
||||
<option value={m}>{m}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Mission</span>
|
||||
<select bind:value={missionFilter}>
|
||||
<option value={null}>alle</option>
|
||||
{#each missions.value as m (m.id)}
|
||||
<option value={m.id}>{m.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if buckets.length === 0}
|
||||
<p class="empty">
|
||||
Noch keine AI-Aktivität{moduleFilter ? ` in ${moduleFilter}` : ''}. Sobald eine Mission
|
||||
läuft und Proposals approved werden, erscheinen hier die Änderungen.
|
||||
</p>
|
||||
{:else}
|
||||
<ol class="timeline">
|
||||
{#each buckets as b (b.key)}
|
||||
<li class="bucket">
|
||||
<header class="bucket-head">
|
||||
<div class="when">
|
||||
<span class="date">{formatDate(b.firstTimestamp)}</span>
|
||||
<span class="time">{formatTime(b.firstTimestamp)}</span>
|
||||
</div>
|
||||
<div class="title-col">
|
||||
<span class="mission-title">
|
||||
{missionTitleById.get(b.missionId) ?? b.missionId}
|
||||
</span>
|
||||
{#if b.rationale}
|
||||
<p class="rationale">{b.rationale}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="revert"
|
||||
disabled={revertingKey !== null}
|
||||
onclick={() => handleRevert(b.key, b.missionId, b.iterationId)}
|
||||
>
|
||||
<ArrowCounterClockwise size={12} />
|
||||
<span>{revertingKey === b.key ? 'Läuft…' : 'Revert'}</span>
|
||||
</button>
|
||||
</header>
|
||||
<ul class="events">
|
||||
{#each b.events as e (e.meta.id)}
|
||||
<li class="event">
|
||||
<span class="mod">{e.meta.appId}</span>
|
||||
<span class="desc">{describeEvent(e)}</span>
|
||||
<a class="link" href={`/${e.meta.appId}`} title="Zum Modul">
|
||||
<ArrowSquareOut size={11} />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
<div class="wb">
|
||||
<div class="filters">
|
||||
<label>
|
||||
<span class="lbl">Modul</span>
|
||||
<select bind:value={moduleFilter}>
|
||||
<option value={null}>alle</option>
|
||||
{#each allModules as m}
|
||||
<option value={m}>{m}</option>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Mission</span>
|
||||
<select bind:value={missionFilter}>
|
||||
<option value={null}>alle</option>
|
||||
{#each missions.value as m (m.id)}
|
||||
<option value={m.id}>{m.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
{#if buckets.length === 0}
|
||||
<p class="empty">
|
||||
Noch keine AI-Aktivität. Sobald eine Mission läuft und Proposals approved werden, erscheinen
|
||||
hier die Änderungen.
|
||||
</p>
|
||||
{:else}
|
||||
<ol class="timeline">
|
||||
{#each buckets as b (b.key)}
|
||||
<li class="bucket">
|
||||
<header class="bucket-head">
|
||||
<div class="when">
|
||||
<span class="date">{formatDate(b.firstTimestamp)}</span>
|
||||
<span class="time">{formatTime(b.firstTimestamp)}</span>
|
||||
</div>
|
||||
<div class="title-col">
|
||||
<span class="mission-title">
|
||||
{missionTitleById.get(b.missionId) ?? b.missionId}
|
||||
</span>
|
||||
{#if b.rationale}
|
||||
<p class="rationale">{b.rationale}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="revert"
|
||||
disabled={revertingKey !== null}
|
||||
onclick={() => handleRevert(b.key, b.missionId, b.iterationId)}
|
||||
>
|
||||
<ArrowCounterClockwise size={12} />
|
||||
<span>{revertingKey === b.key ? 'Läuft…' : 'Revert'}</span>
|
||||
</button>
|
||||
</header>
|
||||
<ul class="events">
|
||||
{#each b.events as e (e.meta.id)}
|
||||
<li class="event">
|
||||
<span class="mod">{e.meta.appId}</span>
|
||||
<span class="desc">{describeEvent(e)}</span>
|
||||
<a class="link" href={`/${e.meta.appId}`} title="Zum Modul">
|
||||
<ArrowSquareOut size={11} />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb {
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
<!--
|
||||
Default "home" page of the Companion carousel. Shows a compact summary
|
||||
of AI activity + one-click buttons to open each of the other pages
|
||||
alongside.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PageShell } from '$lib/components/page-carousel';
|
||||
import { COMPANION_PAGE_META, ALL_COMPANION_PAGE_IDS } from './page-meta';
|
||||
import { companionWorkbenchSettings } from '../stores/workbench-settings.svelte';
|
||||
import type { CompanionPageId } from '../stores/workbench-settings.svelte';
|
||||
import { useAiProposals } from '$lib/data/ai/proposals/queries';
|
||||
import { useMissions } from '$lib/data/ai/missions/queries';
|
||||
|
||||
interface Props {
|
||||
widthPx: number;
|
||||
maximized?: boolean;
|
||||
onClose: () => void;
|
||||
onMaximize: () => void;
|
||||
onResize: (widthPx: number, heightPx?: number) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
widthPx,
|
||||
maximized = false,
|
||||
onClose,
|
||||
onMaximize,
|
||||
onResize,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}: Props = $props();
|
||||
|
||||
const meta = COMPANION_PAGE_META.home;
|
||||
|
||||
const proposals = $derived(useAiProposals({ status: 'pending' }));
|
||||
const missions = $derived(useMissions({ state: 'active' }));
|
||||
|
||||
function openOrFocus(id: CompanionPageId) {
|
||||
companionWorkbenchSettings.openPage(id);
|
||||
}
|
||||
|
||||
function isOpen(id: CompanionPageId): boolean {
|
||||
return companionWorkbenchSettings.openPages.some((p) => p.id === id);
|
||||
}
|
||||
|
||||
// Everything except home — home is always already the current page.
|
||||
const shortcuts = $derived(
|
||||
ALL_COMPANION_PAGE_IDS.filter((id) => id !== 'home').map((id) => COMPANION_PAGE_META[id])
|
||||
);
|
||||
</script>
|
||||
|
||||
<PageShell
|
||||
{widthPx}
|
||||
{maximized}
|
||||
{onClose}
|
||||
{onMaximize}
|
||||
{onResize}
|
||||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
title={meta.title}
|
||||
color={meta.color}
|
||||
icon={meta.icon}
|
||||
>
|
||||
<div class="home">
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{proposals.value.length}</span>
|
||||
<span class="stat-label">wartende Vorschläge</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{missions.value.length}</span>
|
||||
<span class="stat-label">aktive Missions</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3 class="section-title">Bereiche öffnen</h3>
|
||||
<div class="shortcut-grid">
|
||||
{#each shortcuts as s (s.id)}
|
||||
{@const open = isOpen(s.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="shortcut"
|
||||
class:open
|
||||
onclick={() => openOrFocus(s.id)}
|
||||
style="--accent: {s.color};"
|
||||
>
|
||||
<span class="shortcut-icon">
|
||||
<s.icon size={20} weight="bold" />
|
||||
</span>
|
||||
<span class="shortcut-body">
|
||||
<span class="shortcut-title">
|
||||
{s.title}
|
||||
{#if open}<span class="badge-open">offen</span>{/if}
|
||||
</span>
|
||||
<span class="shortcut-desc">{s.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section hint">
|
||||
<p>
|
||||
Jeder Bereich lebt als eigene Page in diesem Carousel. Du kannst mehrere gleichzeitig offen
|
||||
haben, einzeln schließen oder per „×" in der Header-Leiste beenden. Das Layout merkt sich
|
||||
deine Auswahl.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.stat {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--color-surface));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.shortcut-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.shortcut {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.625rem;
|
||||
align-items: start;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-surface));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: hsl(var(--color-foreground));
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
.shortcut:hover {
|
||||
border-color: var(--accent, hsl(var(--color-primary)));
|
||||
background: color-mix(
|
||||
in oklab,
|
||||
var(--accent, hsl(var(--color-primary))) 8%,
|
||||
hsl(var(--color-surface))
|
||||
);
|
||||
}
|
||||
.shortcut.open {
|
||||
border-left: 3px solid var(--accent, hsl(var(--color-primary)));
|
||||
}
|
||||
.shortcut-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.375rem;
|
||||
background: color-mix(in oklab, var(--accent, hsl(var(--color-primary))) 14%, transparent);
|
||||
color: var(--accent, hsl(var(--color-primary)));
|
||||
}
|
||||
.shortcut-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.shortcut-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.shortcut-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.badge-open {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--accent, hsl(var(--color-primary))) 20%, transparent);
|
||||
color: var(--accent, hsl(var(--color-primary)));
|
||||
}
|
||||
|
||||
.hint p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
<!--
|
||||
Chat page — conversation list + active chat. The conversation sidebar
|
||||
lives inline in the pane; picking a conversation swaps the chat area.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { PageShell } from '$lib/components/page-carousel';
|
||||
import { Plus, Trash, Robot } from '@mana/shared-icons';
|
||||
import CompanionChat from '$lib/modules/companion/components/CompanionChat.svelte';
|
||||
import { chatStore } from '$lib/modules/companion/stores/chat.svelte';
|
||||
import { useConversations } from '$lib/modules/companion/queries';
|
||||
import type { LocalConversation } from '$lib/modules/companion/types';
|
||||
import { COMPANION_PAGE_META } from './page-meta';
|
||||
|
||||
interface Props {
|
||||
widthPx: number;
|
||||
maximized?: boolean;
|
||||
onClose: () => void;
|
||||
onMaximize: () => void;
|
||||
onResize: (widthPx: number, heightPx?: number) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
widthPx,
|
||||
maximized = false,
|
||||
onClose,
|
||||
onMaximize,
|
||||
onResize,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}: Props = $props();
|
||||
|
||||
const meta = COMPANION_PAGE_META.chat;
|
||||
const conversations = useConversations();
|
||||
|
||||
let activeConversation = $state<LocalConversation | null>(null);
|
||||
let showList = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
if (conversations.value.length > 0) {
|
||||
activeConversation = conversations.value[0];
|
||||
showList = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!activeConversation && conversations.value.length > 0) {
|
||||
activeConversation = conversations.value[0];
|
||||
}
|
||||
});
|
||||
|
||||
async function handleNewConversation() {
|
||||
const conv = await chatStore.createConversation();
|
||||
activeConversation = conv;
|
||||
showList = false;
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(id: string) {
|
||||
await chatStore.deleteConversation(id);
|
||||
if (activeConversation?.id === id) {
|
||||
activeConversation = conversations.value.find((c) => c.id !== id) ?? null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageShell
|
||||
{widthPx}
|
||||
{maximized}
|
||||
{onClose}
|
||||
{onMaximize}
|
||||
{onResize}
|
||||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
title={meta.title}
|
||||
color={meta.color}
|
||||
icon={meta.icon}
|
||||
>
|
||||
<div class="chat">
|
||||
<div class="bar">
|
||||
<button type="button" class="toggle" onclick={() => (showList = !showList)}>
|
||||
{showList ? 'Zum Chat' : `Gespräche (${conversations.value.length})`}
|
||||
</button>
|
||||
<button type="button" class="new" onclick={handleNewConversation}>
|
||||
<Plus size={14} /><span>Neu</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showList}
|
||||
<div class="list">
|
||||
{#each conversations.value as c (c.id)}
|
||||
<div class="conv" class:active={activeConversation?.id === c.id}>
|
||||
<button
|
||||
type="button"
|
||||
class="conv-main"
|
||||
onclick={() => {
|
||||
activeConversation = c;
|
||||
showList = false;
|
||||
}}
|
||||
>
|
||||
{c.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="conv-del"
|
||||
onclick={() => handleDeleteConversation(c.id)}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash size={11} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if conversations.value.length === 0}
|
||||
<p class="empty">Noch keine Gespräche — klick auf „Neu".</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeConversation}
|
||||
{#key activeConversation.id}
|
||||
<CompanionChat conversation={activeConversation} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<Robot size={36} weight="thin" />
|
||||
<h3>Mana Companion</h3>
|
||||
<button type="button" class="start" onclick={handleNewConversation}>Gespräch starten</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.toggle,
|
||||
.new {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-surface));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.new {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.conv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.conv:hover {
|
||||
border-color: hsl(var(--color-border));
|
||||
}
|
||||
.conv.active {
|
||||
background: color-mix(in oklab, hsl(var(--color-primary)) 8%, transparent);
|
||||
}
|
||||
.conv-main {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.conv-del {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.empty {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.625rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.empty-state h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.start {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid hsl(var(--color-primary));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
<!--
|
||||
Health page — runner status + manual trigger. Shows foreground tick
|
||||
state directly; the server-side mana-ai status falls through to the
|
||||
status.mana.how check link because cross-origin /metrics scraping
|
||||
from the browser isn't worth the CORS dance.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PageShell } from '$lib/components/page-carousel';
|
||||
import { COMPANION_PAGE_META } from './page-meta';
|
||||
import { isMissionTickRunning } from '$lib/data/ai/missions/setup';
|
||||
import { runDueMissions } from '$lib/data/ai/missions/runner';
|
||||
import { productionDeps } from '$lib/data/ai/missions/setup';
|
||||
|
||||
interface Props {
|
||||
widthPx: number;
|
||||
maximized?: boolean;
|
||||
onClose: () => void;
|
||||
onMaximize: () => void;
|
||||
onResize: (widthPx: number, heightPx?: number) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
widthPx,
|
||||
maximized = false,
|
||||
onClose,
|
||||
onMaximize,
|
||||
onResize,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}: Props = $props();
|
||||
|
||||
const meta = COMPANION_PAGE_META.health;
|
||||
|
||||
let lastRunStats = $state<{ at: string; plansProduced: number; errors: number } | null>(null);
|
||||
let manualRunning = $state(false);
|
||||
|
||||
async function triggerManual() {
|
||||
manualRunning = true;
|
||||
try {
|
||||
const results = await runDueMissions(new Date(), productionDeps);
|
||||
let plansProduced = 0;
|
||||
let errors = 0;
|
||||
for (const r of results) {
|
||||
plansProduced += r.plannedSteps;
|
||||
errors += r.failedSteps;
|
||||
}
|
||||
lastRunStats = {
|
||||
at: new Date().toLocaleString('de-DE'),
|
||||
plansProduced,
|
||||
errors,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[health] manual tick failed:', err);
|
||||
lastRunStats = {
|
||||
at: new Date().toLocaleString('de-DE'),
|
||||
plansProduced: 0,
|
||||
errors: 1,
|
||||
};
|
||||
} finally {
|
||||
manualRunning = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageShell
|
||||
{widthPx}
|
||||
{maximized}
|
||||
{onClose}
|
||||
{onMaximize}
|
||||
{onResize}
|
||||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
title={meta.title}
|
||||
color={meta.color}
|
||||
icon={meta.icon}
|
||||
>
|
||||
<div class="h">
|
||||
<section>
|
||||
<h3>Foreground-Runner (dieser Tab)</h3>
|
||||
<dl>
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
{#if isMissionTickRunning()}
|
||||
<span class="ok">● aktiv · 60 s Interval</span>
|
||||
{:else}
|
||||
<span class="ko">● nicht aktiv</span>
|
||||
{/if}
|
||||
</dd>
|
||||
{#if lastRunStats}
|
||||
<dt>Letzter Manual-Tick</dt>
|
||||
<dd>
|
||||
{lastRunStats.at} · {lastRunStats.plansProduced} Plans ·
|
||||
{lastRunStats.errors} Fehler
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
<button type="button" class="run" onclick={triggerManual} disabled={manualRunning}>
|
||||
{manualRunning ? 'Läuft…' : '⚡ Manual Tick'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Server-Runner (mana-ai)</h3>
|
||||
<p class="desc">
|
||||
Läuft unabhängig vom Browser. Status + Uptime werden von Prometheus gescrapet und auf dem
|
||||
Status-Dashboard angezeigt.
|
||||
</p>
|
||||
<a class="external" href="https://status.mana.how" target="_blank" rel="noreferrer">
|
||||
status.mana.how öffnen →
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Datenlage (lokal)</h3>
|
||||
<p class="desc">
|
||||
Alles in diesem Carousel kommt aus IndexedDB. Kein Server-Call außer beim Planner selbst.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.h {
|
||||
padding: 0.75rem 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
section h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
dt {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
.ok {
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.ko {
|
||||
color: #8a1b1b;
|
||||
}
|
||||
.run {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-surface));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.run:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.desc {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.45;
|
||||
}
|
||||
.external {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
<!--
|
||||
Picker for the Companion page carousel — shows every page that isn't
|
||||
currently open and lets the user add it. Mirrors the PagePicker
|
||||
pattern from the todo module.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ALL_COMPANION_PAGE_IDS, COMPANION_PAGE_META } from './page-meta';
|
||||
import type { CompanionPageId } from '../stores/workbench-settings.svelte';
|
||||
|
||||
interface Props {
|
||||
openIds: CompanionPageId[];
|
||||
onPick: (id: CompanionPageId) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { openIds, onPick, onClose }: Props = $props();
|
||||
|
||||
const availableIds = $derived(ALL_COMPANION_PAGE_IDS.filter((id) => !openIds.includes(id)));
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<header class="head">
|
||||
<span class="title">Page öffnen</span>
|
||||
<button type="button" onclick={onClose} aria-label="Schließen">×</button>
|
||||
</header>
|
||||
|
||||
{#if availableIds.length === 0}
|
||||
<p class="empty">Alle Pages sind bereits geöffnet.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each availableIds as id (id)}
|
||||
{@const m = COMPANION_PAGE_META[id]}
|
||||
<li>
|
||||
<button type="button" class="row" onclick={() => onPick(id)} style="--accent: {m.color};">
|
||||
<span class="icon"><m.icon size={16} weight="bold" /></span>
|
||||
<span class="body">
|
||||
<span class="t">{m.title}</span>
|
||||
<span class="d">{m.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
width: 320px;
|
||||
padding: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
border: 2px solid hsl(0 0% 0% / 0.12);
|
||||
border-radius: 1.25rem;
|
||||
align-self: stretch;
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.5rem 0.75rem;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.head button {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-surface));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.row:hover {
|
||||
border-color: var(--accent, hsl(var(--color-primary)));
|
||||
background: color-mix(
|
||||
in oklab,
|
||||
var(--accent, hsl(var(--color-primary))) 8%,
|
||||
hsl(var(--color-surface))
|
||||
);
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: color-mix(in oklab, var(--accent, hsl(var(--color-primary))) 14%, transparent);
|
||||
color: var(--accent, hsl(var(--color-primary)));
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.t {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.d {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* Static metadata for every page the Companion carousel can render.
|
||||
*
|
||||
* Matches the shape modules like `/todo` use in their
|
||||
* `PAGE_META: Record<string, { title; color }>` — adapted here to the
|
||||
* Companion's own page set. Colors map loosely to the activity type
|
||||
* (data: muted, creative: primary-ish, health: green, policy: orange).
|
||||
*/
|
||||
|
||||
import { Sparkle, ChatCircle, Flag, Notebook, Lightning, Heartbeat } from '@mana/shared-icons';
|
||||
import type { Component } from 'svelte';
|
||||
import type { CompanionPageId } from '../stores/workbench-settings.svelte';
|
||||
|
||||
export interface CompanionPageMeta {
|
||||
id: CompanionPageId;
|
||||
title: string;
|
||||
shortLabel: string;
|
||||
color: string;
|
||||
icon: Component;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const COMPANION_PAGE_META: Record<CompanionPageId, CompanionPageMeta> = {
|
||||
home: {
|
||||
id: 'home',
|
||||
title: 'Companion',
|
||||
shortLabel: 'Home',
|
||||
color: '#6B5BFF',
|
||||
icon: Sparkle,
|
||||
description: 'Übersicht, schnelle Einstiege, letzte Aktivität.',
|
||||
},
|
||||
chat: {
|
||||
id: 'chat',
|
||||
title: 'Chat',
|
||||
shortLabel: 'Chat',
|
||||
color: '#3B82F6',
|
||||
icon: ChatCircle,
|
||||
description: 'Gespräch mit der KI.',
|
||||
},
|
||||
missions: {
|
||||
id: 'missions',
|
||||
title: 'Missions',
|
||||
shortLabel: 'Missions',
|
||||
color: '#22C55E',
|
||||
icon: Flag,
|
||||
description: 'Langlebige Aufträge an die KI anlegen, pausieren, ausführen.',
|
||||
},
|
||||
workbench: {
|
||||
id: 'workbench',
|
||||
title: 'Workbench',
|
||||
shortLabel: 'Workbench',
|
||||
color: '#F59E0B',
|
||||
icon: Notebook,
|
||||
description: 'Timeline aller KI-Aktivität; rückgängig machen.',
|
||||
},
|
||||
rituals: {
|
||||
id: 'rituals',
|
||||
title: 'Rituale',
|
||||
shortLabel: 'Rituale',
|
||||
color: '#EC4899',
|
||||
icon: Lightning,
|
||||
description: 'Geführte Routinen (Morgen, Abend, …).',
|
||||
},
|
||||
policy: {
|
||||
id: 'policy',
|
||||
title: 'Policy',
|
||||
shortLabel: 'Policy',
|
||||
color: '#F97316',
|
||||
icon: Flag,
|
||||
description: 'Pro Tool festlegen: auto / propose / deny.',
|
||||
},
|
||||
insights: {
|
||||
id: 'insights',
|
||||
title: 'Insights',
|
||||
shortLabel: 'Insights',
|
||||
color: '#8B5CF6',
|
||||
icon: Notebook,
|
||||
description: 'Approval-Raten, Feedback-Muster, Stats pro Mission.',
|
||||
},
|
||||
health: {
|
||||
id: 'health',
|
||||
title: 'Health',
|
||||
shortLabel: 'Health',
|
||||
color: '#10B981',
|
||||
icon: Heartbeat,
|
||||
description: 'Runner-Status, letzter Tick, LLM-Backend.',
|
||||
},
|
||||
};
|
||||
|
||||
export const ALL_COMPANION_PAGE_IDS: readonly CompanionPageId[] = [
|
||||
'home',
|
||||
'chat',
|
||||
'missions',
|
||||
'workbench',
|
||||
'rituals',
|
||||
'policy',
|
||||
'insights',
|
||||
'health',
|
||||
];
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
/**
|
||||
* Companion workbench settings — which pages the user currently has
|
||||
* open + their widths. Persisted to localStorage so layout survives
|
||||
* reloads; keep it tiny on purpose (no Dexie round-trip for every
|
||||
* resize).
|
||||
*/
|
||||
|
||||
export type CompanionPageId =
|
||||
| 'home'
|
||||
| 'chat'
|
||||
| 'missions'
|
||||
| 'workbench'
|
||||
| 'rituals'
|
||||
| 'policy'
|
||||
| 'insights'
|
||||
| 'health';
|
||||
|
||||
export interface CompanionOpenPage {
|
||||
id: CompanionPageId;
|
||||
widthPx: number;
|
||||
heightPx?: number;
|
||||
maximized?: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'companion:openPages';
|
||||
const DEFAULT_WIDTH = 520;
|
||||
|
||||
/** Fresh users open the home page with shortcuts to everything else. */
|
||||
const DEFAULT_OPEN_PAGES: CompanionOpenPage[] = [{ id: 'home', widthPx: DEFAULT_WIDTH }];
|
||||
|
||||
function loadOpenPages(): CompanionOpenPage[] {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_OPEN_PAGES;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_OPEN_PAGES;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return DEFAULT_OPEN_PAGES;
|
||||
return parsed as CompanionOpenPage[];
|
||||
} catch {
|
||||
return DEFAULT_OPEN_PAGES;
|
||||
}
|
||||
}
|
||||
|
||||
function persist(pages: CompanionOpenPage[]): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(pages));
|
||||
} catch {
|
||||
// ignore quota / private-mode failures — layout falls back to default next load
|
||||
}
|
||||
}
|
||||
|
||||
function createStore() {
|
||||
let openPages = $state<CompanionOpenPage[]>(loadOpenPages());
|
||||
|
||||
return {
|
||||
get openPages() {
|
||||
return openPages;
|
||||
},
|
||||
openPage(id: CompanionPageId) {
|
||||
if (openPages.some((p) => p.id === id)) return;
|
||||
openPages = [...openPages, { id, widthPx: DEFAULT_WIDTH }];
|
||||
persist(openPages);
|
||||
},
|
||||
closePage(id: CompanionPageId) {
|
||||
openPages = openPages.filter((p) => p.id !== id);
|
||||
persist(openPages);
|
||||
},
|
||||
resize(id: CompanionPageId, widthPx: number, heightPx?: number) {
|
||||
openPages = openPages.map((p) => (p.id === id ? { ...p, widthPx, heightPx } : p));
|
||||
persist(openPages);
|
||||
},
|
||||
toggleMaximized(id: CompanionPageId) {
|
||||
openPages = openPages.map((p) => (p.id === id ? { ...p, maximized: !p.maximized } : p));
|
||||
persist(openPages);
|
||||
},
|
||||
moveLeft(id: CompanionPageId) {
|
||||
const idx = openPages.findIndex((p) => p.id === id);
|
||||
if (idx <= 0) return;
|
||||
const next = [...openPages];
|
||||
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
||||
openPages = next;
|
||||
persist(openPages);
|
||||
},
|
||||
moveRight(id: CompanionPageId) {
|
||||
const idx = openPages.findIndex((p) => p.id === id);
|
||||
if (idx === -1 || idx >= openPages.length - 1) return;
|
||||
const next = [...openPages];
|
||||
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
||||
openPages = next;
|
||||
persist(openPages);
|
||||
},
|
||||
reset() {
|
||||
openPages = [...DEFAULT_OPEN_PAGES];
|
||||
persist(openPages);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const companionWorkbenchSettings = createStore();
|
||||
|
|
@ -1,66 +1,39 @@
|
|||
<!--
|
||||
Companion — AI-Workbench carousel.
|
||||
|
||||
Replaces the former split `/companion + /companion/missions +
|
||||
/companion/workbench + /companion/rituals` sub-routes with a single
|
||||
PageCarousel. Each feature (Chat, Missions, Workbench, Rituals,
|
||||
Policy, Insights, Health) lives as one page; user opens / closes /
|
||||
reorders / resizes them. Layout persists via localStorage
|
||||
(`companionWorkbenchSettings`).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PageCarousel } from '$lib/components/page-carousel';
|
||||
import { companionWorkbenchSettings } from '$lib/modules/companion/stores/workbench-settings.svelte';
|
||||
import type { CompanionPageId } from '$lib/modules/companion/stores/workbench-settings.svelte';
|
||||
import { COMPANION_PAGE_META } from '$lib/modules/companion/pages/page-meta';
|
||||
import PagePicker from '$lib/modules/companion/pages/PagePicker.svelte';
|
||||
import AiHomePage from '$lib/modules/companion/pages/AiHomePage.svelte';
|
||||
import ChatPage from '$lib/modules/companion/pages/ChatPage.svelte';
|
||||
import MissionsPage from '$lib/modules/companion/pages/MissionsPage.svelte';
|
||||
import WorkbenchPage from '$lib/modules/companion/pages/WorkbenchPage.svelte';
|
||||
import RitualsPage from '$lib/modules/companion/pages/RitualsPage.svelte';
|
||||
import PolicyPage from '$lib/modules/companion/pages/PolicyPage.svelte';
|
||||
import InsightsPage from '$lib/modules/companion/pages/InsightsPage.svelte';
|
||||
import HealthPage from '$lib/modules/companion/pages/HealthPage.svelte';
|
||||
import type { CarouselPage } from '$lib/components/page-carousel';
|
||||
import { onMount } from 'svelte';
|
||||
import { Robot, Plus, Trash } from '@mana/shared-icons';
|
||||
import CompanionChat from '$lib/modules/companion/components/CompanionChat.svelte';
|
||||
import { chatStore } from '$lib/modules/companion/stores/chat.svelte';
|
||||
import { useConversations } from '$lib/modules/companion/queries';
|
||||
import type { LocalConversation } from '$lib/modules/companion/types';
|
||||
|
||||
let showPicker = $state(false);
|
||||
const conversations = useConversations();
|
||||
|
||||
const openPages = $derived(companionWorkbenchSettings.openPages);
|
||||
let activeConversation = $state<LocalConversation | null>(null);
|
||||
|
||||
const carouselPages = $derived<CarouselPage[]>(
|
||||
openPages.map((p) => {
|
||||
const meta = COMPANION_PAGE_META[p.id];
|
||||
return {
|
||||
id: p.id,
|
||||
title: meta.title,
|
||||
color: meta.color,
|
||||
icon: meta.icon,
|
||||
widthPx: p.widthPx,
|
||||
heightPx: p.heightPx,
|
||||
maximized: p.maximized ?? false,
|
||||
};
|
||||
})
|
||||
);
|
||||
onMount(async () => {
|
||||
// Auto-create or resume last conversation
|
||||
if (conversations.value.length > 0) {
|
||||
activeConversation = conversations.value[0];
|
||||
}
|
||||
});
|
||||
|
||||
function close(id: CompanionPageId) {
|
||||
companionWorkbenchSettings.closePage(id);
|
||||
// When conversations load, select the first one
|
||||
$effect(() => {
|
||||
if (!activeConversation && conversations.value.length > 0) {
|
||||
activeConversation = conversations.value[0];
|
||||
}
|
||||
});
|
||||
|
||||
async function handleNewConversation() {
|
||||
const conv = await chatStore.createConversation();
|
||||
activeConversation = conv;
|
||||
}
|
||||
function maximize(id: CompanionPageId) {
|
||||
companionWorkbenchSettings.toggleMaximized(id);
|
||||
}
|
||||
function resize(id: CompanionPageId, widthPx: number, heightPx?: number) {
|
||||
companionWorkbenchSettings.resize(id, widthPx, heightPx);
|
||||
}
|
||||
function moveLeft(id: CompanionPageId) {
|
||||
companionWorkbenchSettings.moveLeft(id);
|
||||
}
|
||||
function moveRight(id: CompanionPageId) {
|
||||
companionWorkbenchSettings.moveRight(id);
|
||||
}
|
||||
function pick(id: CompanionPageId) {
|
||||
companionWorkbenchSettings.openPage(id);
|
||||
showPicker = false;
|
||||
|
||||
async function handleDeleteConversation(id: string) {
|
||||
await chatStore.deleteConversation(id);
|
||||
if (activeConversation?.id === id) {
|
||||
activeConversation = conversations.value.find((c) => c.id !== id) ?? null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -68,51 +41,268 @@
|
|||
<title>Companion - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<PageCarousel
|
||||
pages={carouselPages}
|
||||
{showPicker}
|
||||
onTogglePicker={() => (showPicker = !showPicker)}
|
||||
addLabel="Page hinzufügen"
|
||||
>
|
||||
{#snippet page(p)}
|
||||
{@const pageId = p.id as CompanionPageId}
|
||||
{@const idx = openPages.findIndex((o) => o.id === pageId)}
|
||||
{@const first = idx === 0}
|
||||
{@const last = idx === openPages.length - 1}
|
||||
{@const shellProps = {
|
||||
widthPx: p.widthPx,
|
||||
maximized: p.maximized,
|
||||
onClose: () => close(pageId),
|
||||
onMaximize: () => maximize(pageId),
|
||||
onResize: (w: number, h?: number) => resize(pageId, w, h),
|
||||
onMoveLeft: first ? undefined : () => moveLeft(pageId),
|
||||
onMoveRight: last ? undefined : () => moveRight(pageId),
|
||||
}}
|
||||
<div class="companion-page">
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-title">
|
||||
<Robot size={20} weight="bold" />
|
||||
<span>Companion</span>
|
||||
</div>
|
||||
<button class="new-btn" onclick={handleNewConversation} title="Neues Gespraech">
|
||||
<Plus size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if pageId === 'home'}
|
||||
<AiHomePage {...shellProps} />
|
||||
{:else if pageId === 'chat'}
|
||||
<ChatPage {...shellProps} />
|
||||
{:else if pageId === 'missions'}
|
||||
<MissionsPage {...shellProps} />
|
||||
{:else if pageId === 'workbench'}
|
||||
<WorkbenchPage {...shellProps} />
|
||||
{:else if pageId === 'rituals'}
|
||||
<RitualsPage {...shellProps} />
|
||||
{:else if pageId === 'policy'}
|
||||
<PolicyPage {...shellProps} />
|
||||
{:else if pageId === 'insights'}
|
||||
<InsightsPage {...shellProps} />
|
||||
{:else if pageId === 'health'}
|
||||
<HealthPage {...shellProps} />
|
||||
<div class="conversation-list">
|
||||
{#each conversations.value as conv (conv.id)}
|
||||
<button
|
||||
class="conversation-item"
|
||||
class:active={activeConversation?.id === conv.id}
|
||||
onclick={() => (activeConversation = conv)}
|
||||
>
|
||||
<span class="conv-title">{conv.title}</span>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="conv-delete"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteConversation(conv.id);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
handleDeleteConversation(conv.id);
|
||||
}
|
||||
}}
|
||||
title="Loeschen"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if conversations.value.length === 0}
|
||||
<p class="empty-hint">Noch keine Gespraeche. Starte mit dem + Button.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-footer">
|
||||
<a href="/companion/missions">AI Missions →</a>
|
||||
<a href="/companion/workbench">Workbench →</a>
|
||||
<a href="/companion/rituals">Rituale →</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="chat-area">
|
||||
{#if activeConversation}
|
||||
{#key activeConversation.id}
|
||||
<CompanionChat conversation={activeConversation} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<Robot size={48} weight="thin" />
|
||||
<h2>Mana Companion</h2>
|
||||
<p>
|
||||
Dein persoenlicher Assistent. Frag nach deinem Tag, lass Tasks erstellen oder Getraenke
|
||||
loggen.
|
||||
</p>
|
||||
<button class="start-btn" onclick={handleNewConversation}> Gespraech starten </button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet picker()}
|
||||
<PagePicker
|
||||
openIds={openPages.map((p) => p.id)}
|
||||
onPick={pick}
|
||||
onClose={() => (showPicker = false)}
|
||||
/>
|
||||
{/snippet}
|
||||
</PageCarousel>
|
||||
<style>
|
||||
.companion-page {
|
||||
display: flex;
|
||||
min-height: 400px;
|
||||
height: calc(100dvh - var(--bottom-chrome-height, 80px) - 6rem);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--color-background));
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: hsl(var(--color-card));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.new-btn:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.conversation-item:hover .conv-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.conv-delete:hover {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
background: hsl(var(--color-background));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
max-width: 320px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--color-border, #ddd);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.sidebar-footer a {
|
||||
color: var(--color-muted, #888);
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.sidebar-footer a:hover {
|
||||
color: var(--color-primary, #6b5bff);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue