mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:41:09 +02:00
feat(companion): refactor into PageCarousel — every AI feature is a page
Drops the split /companion/missions + /companion/workbench +
/companion/rituals sub-routes and rebuilds /companion around the
shared PageCarousel pattern (same one /todo uses). Every feature is
now a self-contained page the user opens/closes/reorders/resizes in
one unified surface.
New pages:
- **Home** (default) — compact stats + one-click shortcuts to every
other page, with "offen" badge when already open
- **Chat** — conversation sidebar + active chat inline
- **Missions** — list ↔ create ↔ detail master-detail inside one pane
- **Workbench** — timeline grouped by iteration + Revert per bucket,
Mission filter dropdown (replaces the old ?mission=… query-param)
- **Rituals** — migrated from /companion/rituals
- **Policy** — NEW: 3-way per-tool toggle (auto/propose/deny) with
localStorage-backed overrides merged into DEFAULT_AI_POLICY live
- **Insights** — NEW: approval rate, 14-day bar chart of AI events,
per-mission stats, top-5 recurring user-feedback strings. All from
local Dexie liveQueries, no server calls.
- **Health** — NEW: foreground runner status, manual tick trigger,
link out to status.mana.how for server-runner uptime
Plumbing:
- `stores/workbench-settings.svelte.ts` — persistent open-pages list
in localStorage (id + widthPx + heightPx + maximized); open/close/
resize/moveLeft/moveRight helpers
- `pages/page-meta.ts` — central registry (title, color, icon,
description) consumed by home shortcuts + PagePicker
- `pages/PagePicker.svelte` — lists available (not-yet-open) pages
with icon + description
- Old sub-routes deleted: /companion/{missions,workbench,rituals}/
Webapp tests still 71/71 green; svelte-check clean on the new pages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce944ef14f
commit
9686198a16
15 changed files with 2788 additions and 1628 deletions
|
|
@ -0,0 +1,231 @@
|
|||
<!--
|
||||
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>
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
<!--
|
||||
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>
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
<!--
|
||||
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>
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
<!--
|
||||
Insights page — local aggregation over `_events` and mission
|
||||
iterations. No server calls; everything comes from Dexie liveQueries.
|
||||
-->
|
||||
<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 }[] = [];
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = new Date(now - i * 86_400_000);
|
||||
buckets.push({ day: d.toISOString().slice(0, 10), count: 0 });
|
||||
}
|
||||
for (const e of events.value) {
|
||||
const day = e.meta.timestamp.slice(0, 10);
|
||||
const b = buckets.find((x) => x.day === day);
|
||||
if (b) b.count++;
|
||||
}
|
||||
return buckets;
|
||||
});
|
||||
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;
|
||||
let rejected = 0;
|
||||
let failed = 0;
|
||||
let awaiting = 0;
|
||||
for (const it of m.iterations) {
|
||||
if (it.overallStatus === 'approved') approved++;
|
||||
else if (it.overallStatus === 'rejected') rejected++;
|
||||
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,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
return [...freq.entries()]
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([text, count]) => ({ text, count }));
|
||||
});
|
||||
|
||||
// Simple approval rate: approved / (approved + rejected), ignoring
|
||||
// awaiting/failed.
|
||||
const approvalRate = $derived.by(() => {
|
||||
let a = 0;
|
||||
let r = 0;
|
||||
for (const m of missionStats) {
|
||||
a += m.approved;
|
||||
r += m.rejected;
|
||||
}
|
||||
if (a + r === 0) return null;
|
||||
return Math.round((a / (a + r)) * 100);
|
||||
});
|
||||
</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>
|
||||
|
||||
<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}
|
||||
</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>
|
||||
|
||||
<style>
|
||||
.ins {
|
||||
padding: 0.75rem 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
section h3 {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.big {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-primary));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sub {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.chart {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(14, 1fr);
|
||||
gap: 0.125rem;
|
||||
height: 64px;
|
||||
align-items: end;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.fill {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
background: hsl(var(--color-muted));
|
||||
border-radius: 0.125rem;
|
||||
min-height: 2px;
|
||||
}
|
||||
.fill.has-value {
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
.m-stats {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.m-stats li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.m-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.m-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.n {
|
||||
font-weight: 600;
|
||||
}
|
||||
.n.ok {
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.n.ko {
|
||||
color: #8a1b1b;
|
||||
}
|
||||
.n.wait {
|
||||
color: #8a5a00;
|
||||
}
|
||||
.n.err {
|
||||
color: #8a1b1b;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.legend {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.fb-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.fb-list li {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.fb-count {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.fb-text {
|
||||
font-style: italic;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,633 @@
|
|||
<!--
|
||||
Missions page — list ↔ detail in a single panel. Replaces the former
|
||||
/companion/missions sub-route with a master-detail-inline view.
|
||||
-->
|
||||
<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,
|
||||
pauseMission,
|
||||
resumeMission,
|
||||
completeMission,
|
||||
deleteMission,
|
||||
addIterationFeedback,
|
||||
} from '$lib/data/ai/missions/store';
|
||||
import { runMission } from '$lib/data/ai/missions/runner';
|
||||
import { productionDeps } from '$lib/data/ai/missions/setup';
|
||||
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');
|
||||
let selectedId = $state<string | null>(null);
|
||||
const selected = $derived(
|
||||
selectedId ? (missions.value.find((m) => m.id === selectedId) ?? null) : null
|
||||
);
|
||||
|
||||
// ── Create form ────────────────────────────────────────
|
||||
let formTitle = $state('');
|
||||
let formObjective = $state('');
|
||||
let formConcept = $state('');
|
||||
let formCadenceKind = $state<MissionCadence['kind']>('manual');
|
||||
let formIntervalMin = $state(60);
|
||||
let formDailyHour = $state(9);
|
||||
let formInputs = $state<MissionInputRef[]>([]);
|
||||
let creating = $state(false);
|
||||
|
||||
function buildCadence(): MissionCadence {
|
||||
switch (formCadenceKind) {
|
||||
case 'manual':
|
||||
return { kind: 'manual' };
|
||||
case 'interval':
|
||||
return { kind: 'interval', everyMinutes: formIntervalMin };
|
||||
case 'daily':
|
||||
return { kind: 'daily', atHour: formDailyHour, atMinute: 0 };
|
||||
case 'weekly':
|
||||
return { kind: 'weekly', dayOfWeek: 1, atHour: formDailyHour };
|
||||
case 'cron':
|
||||
return { kind: 'cron', expression: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!formTitle.trim() || !formObjective.trim()) return;
|
||||
creating = true;
|
||||
try {
|
||||
const m = await createMission({
|
||||
title: formTitle.trim(),
|
||||
objective: formObjective.trim(),
|
||||
conceptMarkdown: formConcept,
|
||||
inputs: formInputs,
|
||||
cadence: buildCadence(),
|
||||
});
|
||||
formTitle = '';
|
||||
formObjective = '';
|
||||
formConcept = '';
|
||||
formInputs = [];
|
||||
formCadenceKind = 'manual';
|
||||
selectedId = m.id;
|
||||
mode = 'detail';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail actions ─────────────────────────────────────
|
||||
let runningNow = $state(false);
|
||||
async function handleRunNow(m: Mission) {
|
||||
runningNow = true;
|
||||
try {
|
||||
await runMission(m.id, productionDeps);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
runningNow = false;
|
||||
}
|
||||
}
|
||||
|
||||
let feedbackDraft = $state('');
|
||||
async function handleFeedback(m: Mission, iterationId: string) {
|
||||
if (!feedbackDraft.trim()) return;
|
||||
await addIterationFeedback(m.id, iterationId, feedbackDraft.trim());
|
||||
feedbackDraft = '';
|
||||
}
|
||||
|
||||
function describeCadence(c: MissionCadence): string {
|
||||
switch (c.kind) {
|
||||
case 'manual':
|
||||
return 'manuell';
|
||||
case 'interval':
|
||||
return `alle ${c.everyMinutes} min`;
|
||||
case 'daily':
|
||||
return `täglich ${String(c.atHour).padStart(2, '0')}:${String(c.atMinute).padStart(2, '0')}`;
|
||||
case 'weekly':
|
||||
return `wöchentlich (Tag ${c.dayOfWeek}, ${c.atHour}:00)`;
|
||||
case 'cron':
|
||||
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);
|
||||
const deltaMs = d.getTime() - Date.now();
|
||||
const mins = Math.round(Math.abs(deltaMs) / 60_000);
|
||||
const hours = Math.round(mins / 60);
|
||||
const days = Math.round(hours / 24);
|
||||
const value = mins < 60 ? `${mins}m` : hours < 24 ? `${hours}h` : `${days}d`;
|
||||
return deltaMs < 0 ? `vor ${value}` : `in ${value}`;
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
selectedId = id;
|
||||
mode = 'detail';
|
||||
}
|
||||
</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>
|
||||
</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>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.pane-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent);
|
||||
border-radius: 0.375rem;
|
||||
background: color-mix(in oklab, hsl(var(--color-primary)) 12%, hsl(var(--color-surface)));
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.empty {
|
||||
padding: 1.5rem 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.m-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
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;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.m-item:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.m-title {
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-active {
|
||||
background: #22c55e;
|
||||
}
|
||||
.dot-paused {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.dot-done {
|
||||
background: #6b7280;
|
||||
}
|
||||
.dot-archived {
|
||||
background: #374151;
|
||||
}
|
||||
.m-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
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;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.back-btn:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.create label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.create input[type='text'],
|
||||
.create input:not([type]),
|
||||
.create textarea {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
}
|
||||
.create textarea {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
resize: vertical;
|
||||
}
|
||||
.create fieldset {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.cadence-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.inline {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.25rem !important;
|
||||
}
|
||||
.inline-num {
|
||||
width: 4rem;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.detail-actions button {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-surface));
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.detail-actions button.danger {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
margin: 0;
|
||||
}
|
||||
.meta dt {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.meta dd {
|
||||
margin: 0;
|
||||
}
|
||||
details pre {
|
||||
white-space: pre-wrap;
|
||||
padding: 0.5rem;
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
margin: 0.375rem 0 0;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.it {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.it header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.it-date {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.badge {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
background: hsl(var(--color-surface));
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge-awaiting-review {
|
||||
background: #fef0c9;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.badge-approved {
|
||||
background: #d7f7e3;
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.badge-rejected,
|
||||
.badge-failed {
|
||||
background: #f7d7d7;
|
||||
color: #8a1b1b;
|
||||
}
|
||||
.it-summary {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.fb {
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-left: 3px solid hsl(var(--color-border));
|
||||
font-style: italic;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.fb-form {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fb-form textarea {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
}
|
||||
.fb-form button {
|
||||
align-self: flex-end;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-surface));
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
<!--
|
||||
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>
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
<!--
|
||||
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.
|
||||
-->
|
||||
<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> {
|
||||
if (typeof localStorage === 'undefined') return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, PolicyDecision>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveOverrides(overrides: Record<string, PolicyDecision>): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function applyPolicy(overrides: Record<string, PolicyDecision>): void {
|
||||
const nextPolicy: AiPolicy = {
|
||||
...DEFAULT_AI_POLICY,
|
||||
tools: { ...DEFAULT_AI_POLICY.tools, ...overrides },
|
||||
};
|
||||
setAiPolicy(nextPolicy);
|
||||
}
|
||||
|
||||
let overrides = $state<Record<string, PolicyDecision>>(loadOverrides());
|
||||
$effect(() => {
|
||||
applyPolicy(overrides);
|
||||
saveOverrides(overrides);
|
||||
});
|
||||
|
||||
const tools = getTools();
|
||||
type ToolEntry = (typeof tools)[number];
|
||||
const grouped = $derived.by(() => {
|
||||
const byModule = new Map<string, ToolEntry[]>();
|
||||
for (const t of tools) {
|
||||
if (!byModule.has(t.module)) byModule.set(t.module, []);
|
||||
byModule.get(t.module)!.push(t);
|
||||
}
|
||||
return [...byModule.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
function decide(toolName: string): PolicyDecision {
|
||||
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) {
|
||||
const { [toolName]: _, ...rest } = overrides;
|
||||
overrides = rest;
|
||||
} else {
|
||||
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>
|
||||
|
||||
{#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>
|
||||
|
||||
<style>
|
||||
.policy {
|
||||
padding: 0.75rem 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
.info p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.45;
|
||||
}
|
||||
.reset {
|
||||
flex-shrink: 0;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-surface));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
section h3 {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
section li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
section li.overridden {
|
||||
border-left: 3px solid hsl(var(--color-primary));
|
||||
}
|
||||
.t-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.t-name {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.t-desc {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.t-picker {
|
||||
display: inline-flex;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pill {
|
||||
border: none;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pill + .pill {
|
||||
border-left: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.pill.active.pill-auto {
|
||||
background: #d7f7e3;
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.pill.active.pill-propose {
|
||||
background: #fef0c9;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.pill.active.pill-deny {
|
||||
background: #f7d7d7;
|
||||
color: #8a1b1b;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
<!--
|
||||
Rituals page — guided routines. Wraps the existing RitualRunner
|
||||
component from the companion module.
|
||||
-->
|
||||
<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);
|
||||
|
||||
async function createFromTemplate(templateId: string) {
|
||||
const template = RITUAL_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (!template) return;
|
||||
await ritualStore.createFromTemplate(template);
|
||||
showTemplates = false;
|
||||
}
|
||||
</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>
|
||||
|
||||
{#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>
|
||||
{/each}
|
||||
{#if rituals.value.length === 0 && !showTemplates}
|
||||
<li class="empty">Noch keine Rituale — erstelle eines aus einer Vorlage oben.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.r {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem 1.25rem;
|
||||
}
|
||||
.back {
|
||||
align-self: flex-start;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.25rem 0;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent);
|
||||
border-radius: 0.375rem;
|
||||
background: color-mix(in oklab, hsl(var(--color-primary)) 12%, hsl(var(--color-surface)));
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.templates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.template {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-surface));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.template span {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.item-main {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
text-align: left;
|
||||
}
|
||||
.item-del {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.375rem 0.5rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.empty {
|
||||
list-style: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
<!--
|
||||
Workbench page — AI activity timeline grouped by mission iteration,
|
||||
with per-bucket Revert button. Replaces /companion/workbench.
|
||||
-->
|
||||
<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);
|
||||
|
||||
const events = $derived(
|
||||
useAiTimeline({
|
||||
missionId: missionFilter ?? undefined,
|
||||
module: moduleFilter ?? undefined,
|
||||
limit: 500,
|
||||
})
|
||||
);
|
||||
const buckets = $derived(bucketByIteration(events.value));
|
||||
const missions = $derived(useMissions());
|
||||
const missionTitleById = $derived(new Map(missions.value.map((m) => [m.id, m.title])));
|
||||
const allModules = $derived(Array.from(new Set(events.value.map((e) => e.meta.appId))).sort());
|
||||
|
||||
function describeEvent(e: DomainEvent): string {
|
||||
const payload = e.payload as Record<string, unknown> | undefined;
|
||||
const title =
|
||||
payload && 'title' in payload && typeof payload.title === 'string'
|
||||
? payload.title
|
||||
: 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' });
|
||||
}
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
let revertingKey = $state<string | null>(null);
|
||||
async function handleRevert(bucketKey: string, missionId: string, iterationId: string) {
|
||||
if (!confirm('Alle AI-Writes dieser Iteration zurücknehmen?')) return;
|
||||
revertingKey = bucketKey;
|
||||
try {
|
||||
const stats = await revertIteration(missionId, iterationId);
|
||||
const parts = [`${stats.reverted} zurückgenommen`];
|
||||
if (stats.skippedUnsupported > 0) parts.push(`${stats.skippedUnsupported} nicht unterstützt`);
|
||||
if (stats.failed > 0) parts.push(`${stats.failed} fehlgeschlagen`);
|
||||
alert(parts.join(' · '));
|
||||
} catch (err) {
|
||||
console.error('[workbench] revert failed:', err);
|
||||
alert('Revert fehlgeschlagen — siehe Console.');
|
||||
} finally {
|
||||
revertingKey = null;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.wb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem 1.25rem;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.filters select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.25rem;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.empty {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.timeline {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.bucket {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
.bucket-head {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.when {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.when .date {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.when .time {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.mission-title {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.rationale {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.revert {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font: inherit;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.revert:hover:not(:disabled) {
|
||||
color: #8a1b1b;
|
||||
border-color: #e99;
|
||||
background: #fff0f0;
|
||||
}
|
||||
.events {
|
||||
list-style: none;
|
||||
padding: 0 0 0 3.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
.event {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 1fr auto;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0;
|
||||
align-items: center;
|
||||
font-size: 0.8125rem;
|
||||
border-top: 1px dashed hsl(var(--color-border));
|
||||
}
|
||||
.event:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.mod {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.link {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* 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',
|
||||
];
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* 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,39 +1,66 @@
|
|||
<!--
|
||||
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 { 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';
|
||||
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';
|
||||
|
||||
const conversations = useConversations();
|
||||
let showPicker = $state(false);
|
||||
|
||||
let activeConversation = $state<LocalConversation | null>(null);
|
||||
const openPages = $derived(companionWorkbenchSettings.openPages);
|
||||
|
||||
onMount(async () => {
|
||||
// Auto-create or resume last conversation
|
||||
if (conversations.value.length > 0) {
|
||||
activeConversation = conversations.value[0];
|
||||
}
|
||||
});
|
||||
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,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// 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 close(id: CompanionPageId) {
|
||||
companionWorkbenchSettings.closePage(id);
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(id: string) {
|
||||
await chatStore.deleteConversation(id);
|
||||
if (activeConversation?.id === id) {
|
||||
activeConversation = conversations.value.find((c) => c.id !== id) ?? null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -41,268 +68,51 @@
|
|||
<title>Companion - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<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>
|
||||
<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="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 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} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<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>
|
||||
{#snippet picker()}
|
||||
<PagePicker
|
||||
openIds={openPages.map((p) => p.id)}
|
||||
onPick={pick}
|
||||
onClose={() => (showPicker = false)}
|
||||
/>
|
||||
{/snippet}
|
||||
</PageCarousel>
|
||||
|
|
|
|||
|
|
@ -1,717 +0,0 @@
|
|||
<!--
|
||||
/companion/missions — Create and review AI Missions.
|
||||
|
||||
Minimal MVP: list on the left, expanded detail on the right (or below
|
||||
on narrow screens). Inline create form at the top. Clicking a mission
|
||||
shows its iteration history + controls (pause/resume/complete/delete).
|
||||
|
||||
No input picker yet — missions can be created with only concept +
|
||||
objective + cadence. Linking to notes / goals / kontext is a follow-up.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Sparkle, Play, Pause, Check, Trash, Plus, ArrowLeft } from '@mana/shared-icons';
|
||||
import { useMissions } from '$lib/data/ai/missions/queries';
|
||||
import {
|
||||
createMission,
|
||||
pauseMission,
|
||||
resumeMission,
|
||||
completeMission,
|
||||
deleteMission,
|
||||
addIterationFeedback,
|
||||
} from '$lib/data/ai/missions/store';
|
||||
import { runMission } from '$lib/data/ai/missions/runner';
|
||||
import { productionDeps } from '$lib/data/ai/missions/setup';
|
||||
import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte';
|
||||
import type { Mission, MissionCadence, MissionInputRef } from '$lib/data/ai/missions/types';
|
||||
|
||||
const missions = $derived(useMissions());
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
const selected = $derived(missions.value.find((m) => m.id === selectedId) ?? null);
|
||||
|
||||
// ── Create form ────────────────────────────────────────
|
||||
let showForm = $state(false);
|
||||
let formTitle = $state('');
|
||||
let formObjective = $state('');
|
||||
let formConcept = $state('');
|
||||
let formCadenceKind = $state<MissionCadence['kind']>('manual');
|
||||
let formIntervalMin = $state(60);
|
||||
let formDailyHour = $state(9);
|
||||
let formInputs = $state<MissionInputRef[]>([]);
|
||||
let creating = $state(false);
|
||||
|
||||
function buildCadence(): MissionCadence {
|
||||
switch (formCadenceKind) {
|
||||
case 'manual':
|
||||
return { kind: 'manual' };
|
||||
case 'interval':
|
||||
return { kind: 'interval', everyMinutes: formIntervalMin };
|
||||
case 'daily':
|
||||
return { kind: 'daily', atHour: formDailyHour, atMinute: 0 };
|
||||
case 'weekly':
|
||||
return { kind: 'weekly', dayOfWeek: 1, atHour: formDailyHour };
|
||||
case 'cron':
|
||||
return { kind: 'cron', expression: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!formTitle.trim() || !formObjective.trim()) return;
|
||||
creating = true;
|
||||
try {
|
||||
const m = await createMission({
|
||||
title: formTitle.trim(),
|
||||
objective: formObjective.trim(),
|
||||
conceptMarkdown: formConcept,
|
||||
inputs: formInputs,
|
||||
cadence: buildCadence(),
|
||||
});
|
||||
formTitle = '';
|
||||
formObjective = '';
|
||||
formConcept = '';
|
||||
formInputs = [];
|
||||
formCadenceKind = 'manual';
|
||||
showForm = false;
|
||||
selectedId = m.id;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail actions ─────────────────────────────────────
|
||||
let runningNow = $state(false);
|
||||
async function handleRunNow(m: Mission) {
|
||||
runningNow = true;
|
||||
try {
|
||||
await runMission(m.id, productionDeps);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
runningNow = false;
|
||||
}
|
||||
}
|
||||
|
||||
let feedbackDraft = $state('');
|
||||
async function handleFeedback(m: Mission, iterationId: string) {
|
||||
if (!feedbackDraft.trim()) return;
|
||||
await addIterationFeedback(m.id, iterationId, feedbackDraft.trim());
|
||||
feedbackDraft = '';
|
||||
}
|
||||
|
||||
function describeCadence(c: MissionCadence): string {
|
||||
switch (c.kind) {
|
||||
case 'manual':
|
||||
return 'manuell';
|
||||
case 'interval':
|
||||
return `alle ${c.everyMinutes} min`;
|
||||
case 'daily':
|
||||
return `täglich ${String(c.atHour).padStart(2, '0')}:${String(c.atMinute).padStart(2, '0')}`;
|
||||
case 'weekly':
|
||||
return `wöchentlich (Tag ${c.dayOfWeek}, ${c.atHour}:00)`;
|
||||
case 'cron':
|
||||
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);
|
||||
const deltaMs = d.getTime() - Date.now();
|
||||
const abs = Math.abs(deltaMs);
|
||||
const mins = Math.round(abs / 60_000);
|
||||
const hours = Math.round(mins / 60);
|
||||
const days = Math.round(hours / 24);
|
||||
const value = mins < 60 ? `${mins}m` : hours < 24 ? `${hours}h` : `${days}d`;
|
||||
return deltaMs < 0 ? `vor ${value}` : `in ${value}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Missions - Companion</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<a href="/companion" class="back">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Companion</span>
|
||||
</a>
|
||||
<div class="title">
|
||||
<Sparkle size={20} weight="fill" />
|
||||
<h1>AI Missions</h1>
|
||||
</div>
|
||||
<button type="button" class="primary-btn" onclick={() => (showForm = !showForm)}>
|
||||
<Plus size={16} />
|
||||
<span>{showForm ? 'Abbrechen' : 'Neue Mission'}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showForm}
|
||||
<form class="create-form" onsubmit={(e) => (e.preventDefault(), handleCreate())}>
|
||||
<label>
|
||||
<span>Titel</span>
|
||||
<input bind:value={formTitle} placeholder="z.B. Wöchentlicher Goals-Review" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Konkretes Ziel</span>
|
||||
<input bind:value={formObjective} placeholder="Was genau soll die KI erreichen?" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Konzept (Markdown, optional)</span>
|
||||
<textarea
|
||||
bind:value={formConcept}
|
||||
placeholder="# Rahmen Erkläre der KI Kontext, Regeln, Grenzen…"
|
||||
rows="6"
|
||||
></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" />
|
||||
<span>Manuell</span>
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="interval" />
|
||||
<span>Intervall</span>
|
||||
{#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" />
|
||||
<span>Täglich um</span>
|
||||
{#if formCadenceKind === 'daily'}
|
||||
<input class="inline-num" type="number" bind:value={formDailyHour} min="0" max="23" />
|
||||
<span>:00</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary-btn" disabled={creating}>
|
||||
{creating ? 'Erstelle…' : 'Mission anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="layout">
|
||||
<aside class="list">
|
||||
{#if missions.value.length === 0}
|
||||
<p class="empty">Noch keine Missions. Starte mit "Neue Mission".</p>
|
||||
{:else}
|
||||
{#each missions.value as m (m.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="list-item"
|
||||
class:selected={selectedId === m.id}
|
||||
onclick={() => (selectedId = m.id)}
|
||||
>
|
||||
<div class="row-1">
|
||||
<strong>{m.title}</strong>
|
||||
<span class="badge badge-{m.state}">{describeState(m.state)}</span>
|
||||
</div>
|
||||
<div class="row-2">
|
||||
<span>{describeCadence(m.cadence)}</span>
|
||||
{#if m.nextRunAt}
|
||||
<span>nächster Run {formatRelative(m.nextRunAt)}</span>
|
||||
{/if}
|
||||
<span>{m.iterations.length} Iteration{m.iterations.length === 1 ? '' : 'en'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<section class="detail">
|
||||
{#if selected}
|
||||
<header class="detail-header">
|
||||
<h2>{selected.title}</h2>
|
||||
<div class="detail-actions">
|
||||
<a
|
||||
href="/companion/workbench?mission={selected.id}"
|
||||
class="workbench-link"
|
||||
title="Aktivität dieser Mission im Workbench"
|
||||
>
|
||||
Workbench →
|
||||
</a>
|
||||
<button type="button" onclick={() => handleRunNow(selected)} disabled={runningNow}>
|
||||
<Play size={14} />
|
||||
<span>{runningNow ? 'Läuft…' : 'Jetzt ausführen'}</span>
|
||||
</button>
|
||||
{#if selected.state === 'active'}
|
||||
<button type="button" onclick={() => pauseMission(selected.id)}>
|
||||
<Pause size={14} />
|
||||
<span>Pausieren</span>
|
||||
</button>
|
||||
{:else if selected.state === 'paused'}
|
||||
<button type="button" onclick={() => resumeMission(selected.id)}>
|
||||
<Play size={14} />
|
||||
<span>Fortsetzen</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if selected.state !== 'done'}
|
||||
<button type="button" onclick={() => completeMission(selected.id)}>
|
||||
<Check size={14} />
|
||||
<span>Abschließen</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
onclick={() => {
|
||||
if (confirm('Mission löschen?')) {
|
||||
deleteMission(selected.id);
|
||||
selectedId = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl class="meta">
|
||||
<dt>Ziel</dt>
|
||||
<dd>{selected.objective}</dd>
|
||||
<dt>Cadence</dt>
|
||||
<dd>{describeCadence(selected.cadence)}</dd>
|
||||
<dt>Nächster Run</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}
|
||||
<section class="concept">
|
||||
<h3>Konzept</h3>
|
||||
<pre>{selected.conceptMarkdown}</pre>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="iterations">
|
||||
<h3>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="iteration">
|
||||
<header>
|
||||
<span class="it-date">{new Date(it.startedAt).toLocaleString('de-DE')}</span>
|
||||
<span class="badge badge-status-{it.overallStatus}">{it.overallStatus}</span>
|
||||
</header>
|
||||
{#if it.summary}
|
||||
<p class="it-summary">{it.summary}</p>
|
||||
{/if}
|
||||
{#if it.plan.length > 0}
|
||||
<ul class="plan">
|
||||
{#each it.plan as step}
|
||||
<li>
|
||||
<span class="step-status">[{step.status}]</span>
|
||||
{#if step.summary}
|
||||
{step.summary}
|
||||
{:else if step.intent.kind === 'toolCall'}
|
||||
{step.intent.toolName}
|
||||
{:else}
|
||||
Notiz
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if it.userFeedback}
|
||||
<blockquote class="feedback">{it.userFeedback}</blockquote>
|
||||
{:else if !it.finishedAt || it.overallStatus === 'awaiting-review'}
|
||||
<form
|
||||
class="feedback-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}
|
||||
</section>
|
||||
{:else}
|
||||
<p class="empty">Wähle links eine Mission aus, oder erstelle eine neue.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-muted, #888);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--color-fg, inherit);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
|
||||
border-radius: 0.375rem;
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, var(--color-bg, #fff));
|
||||
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--color-border, #ddd);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.create-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.create-form label > span {
|
||||
font-weight: 500;
|
||||
}
|
||||
.create-form input:not([type]),
|
||||
.create-form textarea {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
}
|
||||
.create-form textarea {
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
.create-form fieldset {
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.cadence-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.inline {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.375rem !important;
|
||||
}
|
||||
.inline-num {
|
||||
width: 4.5rem;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.list-item {
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg, #fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-item.selected {
|
||||
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 55%, transparent);
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 6%, var(--color-bg, #fff));
|
||||
}
|
||||
.row-1 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.row-2 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-muted, #888);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--color-muted-bg, #eee);
|
||||
}
|
||||
.badge-active {
|
||||
background: #d7f7e3;
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.badge-paused {
|
||||
background: #fef0c9;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.badge-done {
|
||||
background: #e0e5ec;
|
||||
color: #3b4252;
|
||||
}
|
||||
.badge-archived {
|
||||
background: #e8e8e8;
|
||||
color: #666;
|
||||
}
|
||||
.badge-status-running {
|
||||
background: #d7ecff;
|
||||
color: #0a548b;
|
||||
}
|
||||
.badge-status-awaiting-review {
|
||||
background: #fef0c9;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.badge-status-approved {
|
||||
background: #d7f7e3;
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.badge-status-rejected,
|
||||
.badge-status-failed {
|
||||
background: #f7d7d7;
|
||||
color: #8a1b1b;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.workbench-link {
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted, #666);
|
||||
}
|
||||
.workbench-link:hover {
|
||||
color: var(--color-primary, #6b5bff);
|
||||
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
|
||||
}
|
||||
.detail-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.detail-actions button.danger {
|
||||
color: #8a1b1b;
|
||||
}
|
||||
.detail-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.25rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.meta dt {
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
.meta dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.concept pre {
|
||||
white-space: pre-wrap;
|
||||
padding: 0.75rem;
|
||||
background: color-mix(in oklab, var(--color-fg, #000) 3%, transparent);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.iterations h3,
|
||||
.concept h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.iteration {
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.iteration header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.it-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
.it-summary {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.plan {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.plan li {
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
.step-status {
|
||||
color: var(--color-muted, #888);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.75rem;
|
||||
margin-right: 0.375rem;
|
||||
}
|
||||
.feedback {
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-left: 3px solid var(--color-border, #ddd);
|
||||
font-style: italic;
|
||||
color: var(--color-muted, #666);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.feedback-form {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.feedback-form textarea {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
.feedback-form button {
|
||||
align-self: flex-end;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg, #fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-muted, #888);
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Plus, Play, Pause, Trash } from '@mana/shared-icons';
|
||||
import RitualRunner from '$lib/modules/companion/components/RitualRunner.svelte';
|
||||
import {
|
||||
ritualStore,
|
||||
useActiveRituals,
|
||||
useAllRituals,
|
||||
RITUAL_TEMPLATES,
|
||||
} from '$lib/companion/rituals';
|
||||
import type { LocalRitual } from '$lib/companion/rituals/types';
|
||||
|
||||
const rituals = useAllRituals();
|
||||
|
||||
let activeRitual = $state<LocalRitual | null>(null);
|
||||
let showTemplates = $state(false);
|
||||
|
||||
async function createFromTemplate(templateId: string) {
|
||||
const template = RITUAL_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (!template) return;
|
||||
await ritualStore.createFromTemplate(template);
|
||||
showTemplates = false;
|
||||
}
|
||||
|
||||
function startRitual(ritual: LocalRitual) {
|
||||
activeRitual = ritual;
|
||||
}
|
||||
|
||||
function handleComplete() {
|
||||
activeRitual = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rituale - Mana Companion</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if activeRitual}
|
||||
<RitualRunner
|
||||
ritual={activeRitual}
|
||||
onComplete={handleComplete}
|
||||
onClose={() => (activeRitual = null)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="rituals-page">
|
||||
<div class="page-header">
|
||||
<h2>Rituale</h2>
|
||||
<button class="add-btn" onclick={() => (showTemplates = !showTemplates)}>
|
||||
<Plus size={16} weight="bold" /> Neu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showTemplates}
|
||||
<div class="templates">
|
||||
<h3>Vorlage waehlen</h3>
|
||||
{#each RITUAL_TEMPLATES as tpl}
|
||||
<button class="template-card" onclick={() => createFromTemplate(tpl.id)}>
|
||||
<span class="tpl-title">{tpl.title}</span>
|
||||
<span class="tpl-desc">{tpl.description}</span>
|
||||
<span class="tpl-trigger"
|
||||
>{tpl.trigger === 'morning'
|
||||
? 'Morgens'
|
||||
: tpl.trigger === 'evening'
|
||||
? 'Abends'
|
||||
: 'Manuell'}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="ritual-list">
|
||||
{#each rituals.value as ritual (ritual.id)}
|
||||
<div class="ritual-card">
|
||||
<div class="ritual-info">
|
||||
<span class="ritual-title">{ritual.title}</span>
|
||||
{#if ritual.description}
|
||||
<span class="ritual-desc">{ritual.description}</span>
|
||||
{/if}
|
||||
<span class="ritual-trigger">
|
||||
{ritual.trigger === 'morning'
|
||||
? 'Morgens'
|
||||
: ritual.trigger === 'evening'
|
||||
? 'Abends'
|
||||
: 'Manuell'}
|
||||
· {ritual.status === 'active' ? 'Aktiv' : 'Pausiert'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ritual-actions">
|
||||
{#if ritual.status === 'active'}
|
||||
<button class="action-btn play" onclick={() => startRitual(ritual)} title="Starten">
|
||||
<Play size={16} weight="fill" />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={() => ritualStore.pause(ritual.id)}
|
||||
title="Pausieren"
|
||||
>
|
||||
<Pause size={14} weight="bold" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={() => ritualStore.resume(ritual.id)}
|
||||
title="Fortsetzen"
|
||||
>
|
||||
<Play size={14} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="action-btn danger"
|
||||
onclick={() => ritualStore.delete(ritual.id)}
|
||||
title="Loeschen"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Noch keine Rituale. Erstelle eins aus einer Vorlage.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.rituals-page {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.templates {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.templates h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1.5px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-card));
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
background: hsl(var(--color-primary) / 0.03);
|
||||
}
|
||||
|
||||
.tpl-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.tpl-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.tpl-trigger {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ritual-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ritual-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
|
||||
.ritual-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.ritual-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.ritual-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.ritual-trigger {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.ritual-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: hsl(var(--color-muted) / 0.2);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.action-btn.play {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.action-btn.play:hover {
|
||||
background: hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
.action-btn.danger:hover {
|
||||
background: hsl(var(--color-error) / 0.15);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,336 +0,0 @@
|
|||
<!--
|
||||
/companion/workbench — Cross-module timeline of AI activity.
|
||||
|
||||
Renders all events with `meta.actor.kind === 'ai'`, grouped into buckets
|
||||
by mission iteration. Each bucket shows the rationale once (on the
|
||||
iteration header) and lists the resulting record-level events below
|
||||
with a link back into the originating module.
|
||||
|
||||
Filters: mission (via query-string `?mission=…`), module (dropdown).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Sparkle, ArrowLeft, ArrowSquareOut, ArrowCounterClockwise } from '@mana/shared-icons';
|
||||
import { page } from '$app/stores';
|
||||
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';
|
||||
|
||||
const missionId = $derived($page.url.searchParams.get('mission') ?? undefined);
|
||||
let moduleFilter = $state<string | null>(null);
|
||||
|
||||
const events = $derived(
|
||||
useAiTimeline({ missionId, module: moduleFilter ?? undefined, limit: 500 })
|
||||
);
|
||||
const buckets = $derived(bucketByIteration(events.value));
|
||||
const missions = $derived(useMissions());
|
||||
const missionTitleById = $derived(new Map(missions.value.map((m) => [m.id, m.title])));
|
||||
|
||||
const allModules = $derived(Array.from(new Set(events.value.map((e) => e.meta.appId))).sort());
|
||||
|
||||
function moduleRoute(event: DomainEvent): string {
|
||||
// Most module pages live at /{module} — a best-effort deep link.
|
||||
return `/${event.meta.appId}`;
|
||||
}
|
||||
|
||||
function describeEvent(e: DomainEvent): string {
|
||||
const payload = e.payload as Record<string, unknown> | undefined;
|
||||
const title =
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
'title' in payload &&
|
||||
typeof payload.title === 'string'
|
||||
? payload.title
|
||||
: undefined;
|
||||
return title ? `${e.type} · ${title}` : e.type;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
let revertingKey = $state<string | null>(null);
|
||||
|
||||
async function handleRevert(bucketKey: string, missionId: string, iterationId: string) {
|
||||
if (!confirm('Alle AI-Writes dieser Iteration zurücknehmen?')) return;
|
||||
revertingKey = bucketKey;
|
||||
try {
|
||||
const stats = await revertIteration(missionId, iterationId);
|
||||
const parts = [`${stats.reverted} zurückgenommen`];
|
||||
if (stats.skippedUnsupported > 0) parts.push(`${stats.skippedUnsupported} nicht unterstützt`);
|
||||
if (stats.failed > 0) parts.push(`${stats.failed} fehlgeschlagen`);
|
||||
alert(parts.join(' · '));
|
||||
} catch (err) {
|
||||
console.error('[workbench] revert failed:', err);
|
||||
alert('Revert fehlgeschlagen — siehe Console.');
|
||||
} finally {
|
||||
revertingKey = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Workbench - Companion</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<a href="/companion" class="back">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Companion</span>
|
||||
</a>
|
||||
<div class="title">
|
||||
<Sparkle size={20} weight="fill" />
|
||||
<h1>AI Workbench</h1>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>
|
||||
<span class="small">Modul</span>
|
||||
<select bind:value={moduleFilter}>
|
||||
<option value={null}>alle</option>
|
||||
{#each allModules as mod}
|
||||
<option value={mod}>{mod}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if missionId}
|
||||
<a class="chip" href="/companion/workbench">
|
||||
Mission: {missionTitleById.get(missionId) ?? missionId} ✕
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#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 die Änderungen hier.
|
||||
</p>
|
||||
{:else}
|
||||
<ol class="timeline">
|
||||
{#each buckets as b (b.key)}
|
||||
<li class="bucket">
|
||||
<header class="bucket-head">
|
||||
<div class="bucket-when">
|
||||
<span class="date">{formatDate(b.firstTimestamp)}</span>
|
||||
<span class="time">{formatTime(b.firstTimestamp)}</span>
|
||||
</div>
|
||||
<div class="bucket-title">
|
||||
<a href="/companion/missions?m={b.missionId}" class="mission-link">
|
||||
{missionTitleById.get(b.missionId) ?? b.missionId}
|
||||
</a>
|
||||
{#if b.rationale}
|
||||
<p class="rationale">{b.rationale}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="revert-btn"
|
||||
title="Alle Writes dieser Iteration zurücknehmen"
|
||||
disabled={revertingKey !== null}
|
||||
onclick={() => handleRevert(b.key, b.missionId, b.iterationId)}
|
||||
>
|
||||
<ArrowCounterClockwise size={14} />
|
||||
<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="event-mod">{e.meta.appId}</span>
|
||||
<span class="event-desc">{describeEvent(e)}</span>
|
||||
<a class="event-link" href={moduleRoute(e)} title="Zum Modul">
|
||||
<ArrowSquareOut size={12} />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-muted, #888);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
.title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.filters {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.filters label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.filters select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.25rem;
|
||||
font: inherit;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-fg, inherit);
|
||||
text-decoration: none;
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 8%, transparent);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-muted, #888);
|
||||
font-size: 0.9rem;
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bucket {
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg, #fff);
|
||||
}
|
||||
.bucket-head {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.revert-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-muted, #666);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.revert-btn:hover:not(:disabled) {
|
||||
color: #8a1b1b;
|
||||
border-color: #e99;
|
||||
background: #fff0f0;
|
||||
}
|
||||
.revert-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.bucket-when {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-muted, #888);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bucket-when .date {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.bucket-when .time {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.mission-link {
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
|
||||
text-decoration: none;
|
||||
}
|
||||
.mission-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rationale {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-muted, #666);
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.events {
|
||||
list-style: none;
|
||||
padding: 0 0 0 5.25rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.event {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.25rem 0;
|
||||
border-top: 1px dashed color-mix(in oklab, var(--color-border, #ddd) 70%, transparent);
|
||||
}
|
||||
.event:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.event-mod {
|
||||
color: var(--color-muted, #888);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.event-link {
|
||||
color: var(--color-muted, #888);
|
||||
display: inline-flex;
|
||||
}
|
||||
.event-link:hover {
|
||||
color: var(--color-primary, #6b5bff);
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue