mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(ai-agents): Agents UI + Scene binding + Mission picker (Phase 5)
The Multi-Agent Workbench becomes visible. User can now create named
agents with their own persona (name, avatar, role), policy, memory,
and budgets; missions are created against a specific agent; scenes
can be bound to an agent so the Workbench tab shows who's at home.
New UI:
- /ai-agents module (ListView with list/create/detail modes, mirroring
the ai-missions pattern). Detail view exposes:
* Profile (name, avatar emoji, role) + pause/resume/archive/delete
* Behavior (systemPrompt + memory — both encrypted at rest per the
registry)
* Limits (maxConcurrentMissions, maxTokensPerDay)
* Policy editor: global default (auto/propose/deny) + per-module
overrides for the usual suspects (todo / calendar / notes / …).
Three quick-apply templates: Standard, Cautious, Aggressive.
* Clear save path with duplicate-name handling from the store.
- Registered under the 'ai' category alongside the existing AI apps.
New component: AgentPicker — compact <select> over active agents plus
an optional "— keiner —" slot. Reused in:
- ai-missions create flow (new "Agent" fieldset — who executes this
mission; passes agentId through to createMission so the very first
server-iteration writes land under the right principal).
- BindAgentDialog — scene context-menu entry "An Agent binden…"
opens this dialog. Purely a UI lens: scene.viewingAsAgentId ends up
set, SceneAppBar now renders the agent's avatar on the scene tab
next to the name (tooltip on hover). No data-scope change.
Store updates:
- WorkbenchScene gains viewingAsAgentId?; patchScene accepts it;
workbenchScenesStore.setSceneAgent(id, agentId|undefined) helper.
- CreateMissionInput gains agentId?; the ai-missions create form
passes it. MissionPatch also extended so future editors can reassign
a mission to a different agent.
Tests: webapp svelte-check 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cacbfb0764
commit
51e6a20daf
11 changed files with 984 additions and 2 deletions
|
|
@ -832,6 +832,16 @@ registerApp({
|
|||
],
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'news-research',
|
||||
name: 'News Research',
|
||||
color: '#0891B2',
|
||||
icon: Binoculars,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/news-research/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'firsts',
|
||||
name: 'Firsts',
|
||||
|
|
@ -1006,6 +1016,16 @@ registerApp({
|
|||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'ai-agents',
|
||||
name: 'AI Agents',
|
||||
color: '#8B5CF6',
|
||||
icon: Flag,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/ai-agents/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'ai-workbench',
|
||||
name: 'AI Workbench',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
|
|||
// AI Workbench — the 6 new AI feature apps + companion chat
|
||||
companion: 'ai',
|
||||
'ai-missions': 'ai',
|
||||
'ai-agents': 'ai',
|
||||
'ai-workbench': 'ai',
|
||||
'ai-rituals': 'ai',
|
||||
'ai-policy': 'ai',
|
||||
|
|
|
|||
68
apps/mana/apps/web/src/lib/components/ai/AgentPicker.svelte
Normal file
68
apps/mana/apps/web/src/lib/components/ai/AgentPicker.svelte
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!--
|
||||
AgentPicker — compact <select> over the user's active agents plus
|
||||
an "(unset)" option for missions that shouldn't be bound to any
|
||||
specific agent. Used by the mission create/edit flow and the scene
|
||||
settings dialog.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useAgents } from '$lib/data/ai/agents/queries';
|
||||
|
||||
interface Props {
|
||||
/** Current agent id (or undefined for unset / default fallback). */
|
||||
value: string | undefined;
|
||||
/** Fired when the user picks a different agent. Null = cleared. */
|
||||
onSelect: (id: string | undefined) => void;
|
||||
/** When true, the "(keiner)" option is omitted — every mission
|
||||
* must pick an agent. Default false. */
|
||||
required?: boolean;
|
||||
/** Render just the select (default) or a labeled row. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { value, onSelect, required = false, label }: Props = $props();
|
||||
|
||||
const agents = $derived(useAgents({ state: 'active' }));
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
onSelect(v === '' ? undefined : v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
{#if label}
|
||||
<span class="lbl">{label}</span>
|
||||
{/if}
|
||||
<select value={value ?? ''} onchange={handleChange}>
|
||||
{#if !required}
|
||||
<option value="">— keiner —</option>
|
||||
{/if}
|
||||
{#each agents.value as a (a.id)}
|
||||
<option value={a.id}>{a.avatar ?? '🤖'} {a.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,6 +7,13 @@
|
|||
import { tick } from 'svelte';
|
||||
import type { CarouselPage } from '$lib/components/page-carousel/types';
|
||||
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
|
||||
import { useAgents } from '$lib/data/ai/agents/queries';
|
||||
|
||||
// Resolve each scene's bound agent → avatar + name. Cheap lookup
|
||||
// since all active agents are already in memory from the live-
|
||||
// query. No extra Dexie round-trip per render.
|
||||
const agents = $derived(useAgents({ state: 'active' }));
|
||||
const agentById = $derived(new Map(agents.value.map((a) => [a.id, a])));
|
||||
|
||||
interface Props {
|
||||
scenes: WorkbenchScene[];
|
||||
|
|
@ -74,6 +81,7 @@
|
|||
{@const isActive = scene.id === activeSceneId}
|
||||
|
||||
{#if isActive && pages.length > 0}
|
||||
{@const boundAgent = scene.viewingAsAgentId ? agentById.get(scene.viewingAsAgentId) : null}
|
||||
<!-- Active scene + its app tabs wrapped in a visual group -->
|
||||
<div class="scene-group">
|
||||
<button
|
||||
|
|
@ -81,7 +89,11 @@
|
|||
class="scene-pill active"
|
||||
onclick={() => onSceneSelect(scene.id)}
|
||||
oncontextmenu={(e) => onSceneContextMenu(e, scene)}
|
||||
title={boundAgent ? `Agent: ${boundAgent.name}` : undefined}
|
||||
>
|
||||
{#if boundAgent}
|
||||
<span class="scene-agent-avatar">{boundAgent.avatar ?? '🤖'}</span>
|
||||
{/if}
|
||||
<span class="scene-name">{scene.name}</span>
|
||||
<span class="scene-count">{scene.openApps.length}</span>
|
||||
</button>
|
||||
|
|
@ -108,13 +120,18 @@
|
|||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{@const boundAgent = scene.viewingAsAgentId ? agentById.get(scene.viewingAsAgentId) : null}
|
||||
<button
|
||||
type="button"
|
||||
class="scene-pill"
|
||||
class:active={isActive}
|
||||
onclick={() => onSceneSelect(scene.id)}
|
||||
oncontextmenu={(e) => onSceneContextMenu(e, scene)}
|
||||
title={boundAgent ? `Agent: ${boundAgent.name}` : undefined}
|
||||
>
|
||||
{#if boundAgent}
|
||||
<span class="scene-agent-avatar">{boundAgent.avatar ?? '🤖'}</span>
|
||||
{/if}
|
||||
<span class="scene-name">{scene.name}</span>
|
||||
<span class="scene-count">{scene.openApps.length}</span>
|
||||
</button>
|
||||
|
|
@ -219,6 +236,10 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.scene-agent-avatar {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.scene-count {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
<!--
|
||||
BindAgentDialog — small modal for picking (or clearing) the Agent a
|
||||
Scene is "viewed as". Purely a UI lens; see
|
||||
docs/plans/multi-agent-workbench.md §Phase 5d for the rationale.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import AgentPicker from '$lib/components/ai/AgentPicker.svelte';
|
||||
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
|
||||
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
|
||||
|
||||
interface Props {
|
||||
scene: WorkbenchScene | null;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { scene = $bindable(null), open = $bindable(false) }: Props = $props();
|
||||
|
||||
let selection = $state<string | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (scene) selection = scene.viewingAsAgentId;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!scene) return;
|
||||
await workbenchScenesStore.setSceneAgent(scene.id, selection);
|
||||
open = false;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKey} />
|
||||
|
||||
{#if open && scene}
|
||||
<div class="scrim-wrap" role="presentation">
|
||||
<button type="button" class="scrim" aria-label="Schließen" onclick={cancel}></button>
|
||||
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="bind-agent-title">
|
||||
<h2 id="bind-agent-title">Scene an Agent binden</h2>
|
||||
<p class="lede">
|
||||
Die Scene „{scene.name}" zeigt ab dann den Agent-Avatar als Hinweis und setzt ihn in
|
||||
Mission-Filtern vor. Scene-Inhalte ändern sich dadurch nicht.
|
||||
</p>
|
||||
<AgentPicker value={selection} onSelect={(id) => (selection = id)} label="Agent" />
|
||||
<footer>
|
||||
<button type="button" class="btn-ghost" onclick={cancel}>Abbrechen</button>
|
||||
<button type="button" class="btn-primary" onclick={save}>Übernehmen</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scrim-wrap {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel {
|
||||
position: relative;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
max-width: 24rem;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-ghost,
|
||||
.btn-primary {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-ghost {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.btn-primary {
|
||||
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent);
|
||||
background: color-mix(in oklab, hsl(var(--color-primary)) 18%, hsl(var(--color-surface)));
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -47,6 +47,11 @@ export interface CreateMissionInput {
|
|||
objective: string;
|
||||
inputs?: MissionInputRef[];
|
||||
cadence: MissionCadence;
|
||||
/** Owning agent id. Optional — when omitted, the mission inherits
|
||||
* the legacy default agent via the bootstrap migration. Pass an id
|
||||
* explicitly from the create UI so new missions land on the right
|
||||
* agent from the first write. */
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export async function createMission(input: CreateMissionInput): Promise<Mission> {
|
||||
|
|
@ -69,6 +74,7 @@ export async function createMission(input: CreateMissionInput): Promise<Mission>
|
|||
state: 'active',
|
||||
nextRunAt: nextRunForCadence(cadencePlain, new Date()),
|
||||
iterations: [],
|
||||
agentId: input.agentId,
|
||||
};
|
||||
await table().add(mission);
|
||||
return mission;
|
||||
|
|
@ -107,6 +113,7 @@ export interface MissionPatch {
|
|||
objective?: string;
|
||||
inputs?: MissionInputRef[];
|
||||
cadence?: MissionCadence;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export async function updateMission(id: string, patch: MissionPatch): Promise<void> {
|
||||
|
|
|
|||
693
apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte
Normal file
693
apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte
Normal file
|
|
@ -0,0 +1,693 @@
|
|||
<!--
|
||||
AI Agents app — workbench card.
|
||||
|
||||
Master-detail inline (list ↔ create ↔ detail) in a single panel,
|
||||
mirroring the ai-missions module. Detail view exposes:
|
||||
- role + name rename
|
||||
- avatar (emoji)
|
||||
- system-prompt + memory (both are encrypted at rest via the
|
||||
crypto registry)
|
||||
- policy editor (coarse: defaultForAi + a few per-module overrides)
|
||||
- budget + concurrency
|
||||
- archive / delete
|
||||
|
||||
Policy is intentionally exposed in a coarse form for v1 — per-tool
|
||||
overrides are powerful but noisy. The defaultForAi radio gives users
|
||||
a one-click "careful vs aggressive" switch; per-module overrides
|
||||
handle the common "let the agent touch todo but not calendar" case.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Plus, Pause, Play, Archive, Trash } from '@mana/shared-icons';
|
||||
import { useAgents } from '$lib/data/ai/agents/queries';
|
||||
import {
|
||||
createAgent,
|
||||
updateAgent,
|
||||
archiveAgent,
|
||||
pauseAgent,
|
||||
resumeAgent,
|
||||
deleteAgent,
|
||||
DuplicateAgentNameError,
|
||||
} from '$lib/data/ai/agents/store';
|
||||
import { DEFAULT_AI_POLICY } from '$lib/data/ai/policy';
|
||||
import type { Agent } from '$lib/data/ai/agents/types';
|
||||
import type { AiPolicy, PolicyDecision } from '@mana/shared-ai';
|
||||
|
||||
const agents = $derived(useAgents());
|
||||
|
||||
let mode = $state<'list' | 'create' | 'detail'>('list');
|
||||
let selectedId = $state<string | null>(null);
|
||||
const selected = $derived<Agent | null>(
|
||||
selectedId ? (agents.value.find((a) => a.id === selectedId) ?? null) : null
|
||||
);
|
||||
|
||||
// ── Create form ─────────────────────────────────────────
|
||||
let formName = $state('');
|
||||
let formAvatar = $state('🤖');
|
||||
let formRole = $state('');
|
||||
let formError = $state<string | null>(null);
|
||||
let creating = $state(false);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!formName.trim() || !formRole.trim()) return;
|
||||
formError = null;
|
||||
creating = true;
|
||||
try {
|
||||
const a = await createAgent({
|
||||
name: formName.trim(),
|
||||
avatar: formAvatar || undefined,
|
||||
role: formRole.trim(),
|
||||
});
|
||||
formName = '';
|
||||
formAvatar = '🤖';
|
||||
formRole = '';
|
||||
selectedId = a.id;
|
||||
mode = 'detail';
|
||||
} catch (err) {
|
||||
if (err instanceof DuplicateAgentNameError) {
|
||||
formError = `Agent-Name „${err.name}" ist bereits vergeben.`;
|
||||
} else {
|
||||
formError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail edits ────────────────────────────────────────
|
||||
let editName = $state('');
|
||||
let editAvatar = $state('');
|
||||
let editRole = $state('');
|
||||
let editSystemPrompt = $state('');
|
||||
let editMemory = $state('');
|
||||
let editMaxConcurrent = $state(1);
|
||||
let editMaxTokensPerDay = $state<number | null>(null);
|
||||
let lastSyncedId = $state<string | null>(null);
|
||||
let saveError = $state<string | null>(null);
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (selected && selected.id !== lastSyncedId) {
|
||||
editName = selected.name;
|
||||
editAvatar = selected.avatar ?? '';
|
||||
editRole = selected.role;
|
||||
editSystemPrompt = selected.systemPrompt ?? '';
|
||||
editMemory = selected.memory ?? '';
|
||||
editMaxConcurrent = selected.maxConcurrentMissions;
|
||||
editMaxTokensPerDay = selected.maxTokensPerDay ?? null;
|
||||
lastSyncedId = selected.id;
|
||||
saveError = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSave(agent: Agent) {
|
||||
saveError = null;
|
||||
saving = true;
|
||||
try {
|
||||
await updateAgent(agent.id, {
|
||||
name: editName.trim() !== agent.name ? editName.trim() : undefined,
|
||||
avatar: editAvatar || undefined,
|
||||
role: editRole.trim(),
|
||||
systemPrompt: editSystemPrompt || undefined,
|
||||
memory: editMemory || undefined,
|
||||
maxConcurrentMissions: editMaxConcurrent,
|
||||
maxTokensPerDay: editMaxTokensPerDay ?? undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DuplicateAgentNameError) {
|
||||
saveError = `Agent-Name „${err.name}" ist bereits vergeben.`;
|
||||
} else {
|
||||
saveError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Policy editor ───────────────────────────────────────
|
||||
// We expose a compact form of AiPolicy: the global default +
|
||||
// per-module overrides for the handful of modules that matter.
|
||||
// Per-tool overrides are a power-user knob that can come later.
|
||||
const POLICY_MODULES = ['todo', 'calendar', 'notes', 'kontext', 'finance', 'drink', 'food'];
|
||||
const POLICY_CHOICES: PolicyDecision[] = ['auto', 'propose', 'deny'];
|
||||
const POLICY_LABEL: Record<PolicyDecision, string> = {
|
||||
auto: 'Automatisch',
|
||||
propose: 'Vorschlag',
|
||||
deny: 'Verboten',
|
||||
};
|
||||
|
||||
async function setDefaultForAi(agent: Agent, value: PolicyDecision) {
|
||||
await updateAgent(agent.id, {
|
||||
policy: { ...agent.policy, defaultForAi: value },
|
||||
});
|
||||
}
|
||||
|
||||
async function setModuleDefault(agent: Agent, moduleName: string, value: PolicyDecision) {
|
||||
const current = agent.policy.defaultsByModule ?? {};
|
||||
await updateAgent(agent.id, {
|
||||
policy: {
|
||||
...agent.policy,
|
||||
defaultsByModule: { ...current, [moduleName]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function clearModuleDefault(agent: Agent, moduleName: string) {
|
||||
const current = { ...(agent.policy.defaultsByModule ?? {}) };
|
||||
delete current[moduleName];
|
||||
await updateAgent(agent.id, {
|
||||
policy: { ...agent.policy, defaultsByModule: current },
|
||||
});
|
||||
}
|
||||
|
||||
function moduleDecisionOrDefault(policy: AiPolicy, moduleName: string): PolicyDecision | '' {
|
||||
return (policy.defaultsByModule?.[moduleName] ?? '') as PolicyDecision | '';
|
||||
}
|
||||
|
||||
// ── Templates ───────────────────────────────────────────
|
||||
const TEMPLATES: Array<{ key: string; label: string; policy: AiPolicy }> = [
|
||||
{
|
||||
key: 'standard',
|
||||
label: 'Standard (Vorschlag für alles)',
|
||||
policy: DEFAULT_AI_POLICY,
|
||||
},
|
||||
{
|
||||
key: 'cautious',
|
||||
label: 'Vorsichtig (alles Vorschlag, Schreiben verboten)',
|
||||
policy: {
|
||||
...DEFAULT_AI_POLICY,
|
||||
tools: Object.fromEntries(
|
||||
Object.entries(DEFAULT_AI_POLICY.tools).map(([k, v]) => [
|
||||
k,
|
||||
v === 'auto' ? 'auto' : ('propose' as PolicyDecision),
|
||||
])
|
||||
),
|
||||
defaultForAi: 'propose',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'aggressive',
|
||||
label: 'Aggressiv (gleichartige Schreibvorgänge automatisch)',
|
||||
policy: {
|
||||
...DEFAULT_AI_POLICY,
|
||||
defaultsByModule: { drink: 'auto', food: 'auto' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function applyTemplate(agent: Agent, key: string) {
|
||||
const t = TEMPLATES.find((x) => x.key === key);
|
||||
if (!t) return;
|
||||
await updateAgent(agent.id, { policy: t.policy });
|
||||
}
|
||||
|
||||
// ── Lifecycle helpers ───────────────────────────────────
|
||||
function openDetail(id: string) {
|
||||
selectedId = id;
|
||||
mode = 'detail';
|
||||
}
|
||||
|
||||
async function handleDelete(agent: Agent) {
|
||||
if (!confirm(`Agent „${agent.name}" löschen? Missionen laufen orphan weiter.`)) return;
|
||||
await deleteAgent(agent.id);
|
||||
mode = 'list';
|
||||
selectedId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if mode === 'list'}
|
||||
<div class="pane">
|
||||
<header class="bar">
|
||||
<button type="button" class="primary" onclick={() => (mode = 'create')}>
|
||||
<Plus size={14} /><span>Neuer Agent</span>
|
||||
</button>
|
||||
</header>
|
||||
{#if agents.value.length === 0}
|
||||
<p class="empty">
|
||||
Noch keine Agenten. Ein Default-Agent „Mana" wird beim ersten Login automatisch angelegt;
|
||||
für weitere persona-basierte Agenten klicke auf „Neuer Agent".
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="m-list">
|
||||
{#each agents.value as a (a.id)}
|
||||
<li>
|
||||
<button type="button" class="m-item" onclick={() => openDetail(a.id)}>
|
||||
<span class="m-title">
|
||||
<span class="avatar">{a.avatar ?? '🤖'}</span>
|
||||
<span class="m-name">{a.name}</span>
|
||||
<span class="dot dot-{a.state}" title={a.state}></span>
|
||||
</span>
|
||||
<span class="m-meta">
|
||||
<span>{a.role}</span>
|
||||
</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">Name</span>
|
||||
<input bind:value={formName} placeholder="z.B. Travel Planner" required />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Avatar (Emoji)</span>
|
||||
<input bind:value={formAvatar} maxlength="4" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Rolle / Aufgabe</span>
|
||||
<input bind:value={formRole} placeholder="Was macht dieser Agent für dich?" required />
|
||||
</label>
|
||||
{#if formError}
|
||||
<p class="form-error">{formError}</p>
|
||||
{/if}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary" disabled={creating}>
|
||||
{creating ? 'Erstelle…' : 'Agent 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">
|
||||
<span class="avatar">{selected.avatar ?? '🤖'}</span>
|
||||
<span>{selected.name}</span>
|
||||
<span class="state-pill state-{selected.state}">{selected.state}</span>
|
||||
</h2>
|
||||
<div class="detail-actions">
|
||||
{#if selected.state === 'active'}
|
||||
<button type="button" onclick={() => pauseAgent(selected.id)}>
|
||||
<Pause size={12} /><span>Pause</span>
|
||||
</button>
|
||||
{:else if selected.state === 'paused'}
|
||||
<button type="button" onclick={() => resumeAgent(selected.id)}>
|
||||
<Play size={12} /><span>Fortsetzen</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if selected.state !== 'archived'}
|
||||
<button type="button" onclick={() => archiveAgent(selected.id)}>
|
||||
<Archive size={12} /><span>Archivieren</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="danger" onclick={() => handleDelete(selected)}>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="block">
|
||||
<h3>Profil</h3>
|
||||
<label>
|
||||
<span class="lbl">Name</span>
|
||||
<input bind:value={editName} />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Avatar (Emoji)</span>
|
||||
<input bind:value={editAvatar} maxlength="4" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Rolle</span>
|
||||
<input bind:value={editRole} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h3>Verhalten</h3>
|
||||
<label>
|
||||
<span class="lbl">System-Anweisung (verschlüsselt)</span>
|
||||
<textarea
|
||||
bind:value={editSystemPrompt}
|
||||
rows="3"
|
||||
placeholder="Prepends auf jeden Planner-Prompt dieses Agents."
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span class="lbl">Gedächtnis (verschlüsselt)</span>
|
||||
<textarea
|
||||
bind:value={editMemory}
|
||||
rows="5"
|
||||
placeholder="Was der Agent dauerhaft über dich wissen soll."
|
||||
></textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h3>Grenzen</h3>
|
||||
<label class="inline-field">
|
||||
<span class="lbl">Parallele Missionen</span>
|
||||
<input type="number" min="1" max="10" bind:value={editMaxConcurrent} />
|
||||
</label>
|
||||
<label class="inline-field">
|
||||
<span class="lbl">Token-Budget / Tag</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={editMaxTokensPerDay}
|
||||
placeholder="leer = unbegrenzt"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="save-row">
|
||||
{#if saveError}
|
||||
<span class="form-error">{saveError}</span>
|
||||
{/if}
|
||||
<button type="button" class="primary" disabled={saving} onclick={() => handleSave(selected)}>
|
||||
{saving ? 'Speichere…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="block">
|
||||
<h3>Policy</h3>
|
||||
<p class="hint">
|
||||
Entscheidet pro Modul was der Agent autonom darf. Tool-spezifische Feinheiten kommen später.
|
||||
</p>
|
||||
|
||||
<div class="policy-row">
|
||||
<span class="lbl">Template übernehmen</span>
|
||||
<select onchange={(e) => applyTemplate(selected, (e.target as HTMLSelectElement).value)}>
|
||||
<option value="">—</option>
|
||||
{#each TEMPLATES as t (t.key)}
|
||||
<option value={t.key}>{t.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="policy-row">
|
||||
<span class="lbl">Global: wenn kein Modul passt</span>
|
||||
<div class="radio-group">
|
||||
{#each POLICY_CHOICES as c}
|
||||
<label class="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="defaultForAi"
|
||||
value={c}
|
||||
checked={selected.policy.defaultForAi === c}
|
||||
onchange={() => setDefaultForAi(selected, c)}
|
||||
/>
|
||||
<span>{POLICY_LABEL[c]}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="policy-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Modul</th>
|
||||
<th>Entscheidung</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each POLICY_MODULES as mod}
|
||||
{@const current = moduleDecisionOrDefault(selected.policy, mod)}
|
||||
<tr>
|
||||
<td><code>{mod}</code></td>
|
||||
<td>
|
||||
<select
|
||||
value={current}
|
||||
onchange={(e) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
if (!v) clearModuleDefault(selected, mod);
|
||||
else setModuleDefault(selected, mod, v as PolicyDecision);
|
||||
}}
|
||||
>
|
||||
<option value="">Global-Default</option>
|
||||
{#each POLICY_CHOICES as c}
|
||||
<option value={c}>{POLICY_LABEL[c]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pane,
|
||||
.create,
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem 1.5rem;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.empty {
|
||||
padding: 1.5rem 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
.m-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
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.5rem;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.m-name {
|
||||
flex: 1;
|
||||
}
|
||||
.avatar {
|
||||
display: inline-block;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.dot-active {
|
||||
background: #22c55e;
|
||||
}
|
||||
.dot-paused {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.dot-archived {
|
||||
background: #6b7280;
|
||||
}
|
||||
.m-meta {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.create label,
|
||||
.block label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
textarea {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
resize: vertical;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.form-error {
|
||||
color: hsl(var(--color-error));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.detail-title {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.state-pill {
|
||||
margin-left: auto;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.state-active {
|
||||
background: #d7f7e3;
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.state-paused {
|
||||
background: #fde7c8;
|
||||
color: #8a4f00;
|
||||
}
|
||||
.state-archived {
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
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 .danger {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.block h3 {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.inline-field {
|
||||
flex-direction: row !important;
|
||||
gap: 0.5rem !important;
|
||||
align-items: center;
|
||||
}
|
||||
.inline-field input {
|
||||
width: 6rem;
|
||||
}
|
||||
.save-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.policy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.radio-group {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.radio {
|
||||
flex-direction: row !important;
|
||||
gap: 0.25rem !important;
|
||||
align-items: center;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.policy-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.policy-table th,
|
||||
.policy-table td {
|
||||
text-align: left;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.policy-table th {
|
||||
font-weight: 600;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.policy-table code {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
import { productionDeps } from '$lib/data/ai/missions/setup';
|
||||
import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte';
|
||||
import MissionGrantDialog from '$lib/components/ai/MissionGrantDialog.svelte';
|
||||
import AgentPicker from '$lib/components/ai/AgentPicker.svelte';
|
||||
import AiDebugBlock from '$lib/components/ai/AiDebugBlock.svelte';
|
||||
import AiProposalInbox from '$lib/components/ai/AiProposalInbox.svelte';
|
||||
import { isAiDebugEnabled, setAiDebugEnabled } from '$lib/data/ai/missions/debug';
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
let formIntervalMin = $state(60);
|
||||
let formDailyHour = $state(9);
|
||||
let formInputs = $state<MissionInputRef[]>([]);
|
||||
let formAgentId = $state<string | undefined>(undefined);
|
||||
let creating = $state(false);
|
||||
|
||||
function buildCadence(): MissionCadence {
|
||||
|
|
@ -75,11 +77,13 @@
|
|||
conceptMarkdown: formConcept,
|
||||
inputs: formInputs,
|
||||
cadence: buildCadence(),
|
||||
agentId: formAgentId,
|
||||
});
|
||||
formTitle = '';
|
||||
formObjective = '';
|
||||
formConcept = '';
|
||||
formInputs = [];
|
||||
formAgentId = undefined;
|
||||
formCadenceKind = 'manual';
|
||||
selectedId = m.id;
|
||||
mode = 'detail';
|
||||
|
|
@ -255,6 +259,14 @@
|
|||
rows="5"
|
||||
></textarea>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Agent</legend>
|
||||
<AgentPicker
|
||||
value={formAgentId}
|
||||
onSelect={(id) => (formAgentId = id)}
|
||||
label="Wer führt aus"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Inputs (Kontext für die KI)</legend>
|
||||
<MissionInputPicker bind:value={formInputs} />
|
||||
|
|
|
|||
|
|
@ -99,7 +99,10 @@ function pickActiveId(scenes: WorkbenchScene[], current: string | null): string
|
|||
async function patchScene(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<LocalWorkbenchScene, 'name' | 'description' | 'openApps' | 'order' | 'wallpaper'>
|
||||
Pick<
|
||||
LocalWorkbenchScene,
|
||||
'name' | 'description' | 'openApps' | 'order' | 'wallpaper' | 'viewingAsAgentId'
|
||||
>
|
||||
>
|
||||
) {
|
||||
// Strip Svelte 5 $state proxies — IndexedDB's structured clone can't serialize them.
|
||||
|
|
@ -221,6 +224,12 @@ export const workbenchScenesStore = {
|
|||
await patchScene(id, { description });
|
||||
},
|
||||
|
||||
/** Bind the scene to an Agent (or clear the binding). Purely a UI
|
||||
* lens — does not affect which data the open apps can see. */
|
||||
async setSceneAgent(id: string, agentId: string | undefined) {
|
||||
await patchScene(id, { viewingAsAgentId: agentId });
|
||||
},
|
||||
|
||||
async duplicateScene(id: string) {
|
||||
const src = scenesState.find((s) => s.id === id);
|
||||
if (!src) return;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,16 @@ export interface WorkbenchScene {
|
|||
order: number;
|
||||
/** Per-scene wallpaper override. When set, takes priority over globalSettings.wallpaper. */
|
||||
wallpaper?: WallpaperConfig;
|
||||
/**
|
||||
* Optional Agent this scene is "viewed as" (Multi-Agent Workbench
|
||||
* Phase 5). Pure UI lens — does NOT restrict which data the open
|
||||
* apps see. When set, the scene tab shows the agent's avatar, the
|
||||
* Workbench timeline defaults to this agent's filter, and the
|
||||
* mission-create flow pre-selects it. Undefined = neutral scene
|
||||
* (no agent binding); user can pick one explicitly in scene
|
||||
* settings. See docs/plans/multi-agent-workbench.md §Phase 5d.
|
||||
*/
|
||||
viewingAsAgentId?: string;
|
||||
}
|
||||
|
||||
/** Dexie row shape (adds the BaseRecord audit fields stamped by hooks). */
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import SceneAppBar from '$lib/components/workbench/SceneAppBar.svelte';
|
||||
import SceneHeader from '$lib/components/workbench/scenes/SceneHeader.svelte';
|
||||
import ConfirmDialog from '$lib/components/workbench/scenes/ConfirmDialog.svelte';
|
||||
import BindAgentDialog from '$lib/components/workbench/scenes/BindAgentDialog.svelte';
|
||||
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
|
||||
import { getApp, getAppByDragType, isAppAccessible } from '$lib/app-registry';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
|
@ -13,7 +14,7 @@
|
|||
import { DragPreview } from '@mana/shared-ui/dnd';
|
||||
import type { DragType } from '@mana/shared-ui/dnd';
|
||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||
import { Pencil, Copy, Trash, Image } from '@mana/shared-icons';
|
||||
import { Pencil, Copy, Trash, Image, Sparkle } from '@mana/shared-icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { buildContextMenuItems, createWorkbenchContextMenu } from '$lib/context-menu';
|
||||
|
|
@ -273,6 +274,12 @@
|
|||
icon: Copy,
|
||||
action: () => handleDuplicateScene(scene.id),
|
||||
},
|
||||
{
|
||||
id: 'bind-agent',
|
||||
label: scene.viewingAsAgentId ? 'Agent-Bindung ändern…' : 'An Agent binden…',
|
||||
icon: Sparkle,
|
||||
action: () => handleRequestBindAgent(scene.id),
|
||||
},
|
||||
{
|
||||
id: 'wallpaper',
|
||||
label: 'Hintergrund ändern',
|
||||
|
|
@ -295,6 +302,15 @@
|
|||
|
||||
// ── Scene CRUD dialogs ──────────────────────────────────
|
||||
let sceneToDelete = $state<{ id: string; name: string } | null>(null);
|
||||
let sceneToBindAgent = $state<WorkbenchScene | null>(null);
|
||||
let bindAgentDialogOpen = $state(false);
|
||||
|
||||
function handleRequestBindAgent(id: string) {
|
||||
const scene = scenes.find((s) => s.id === id);
|
||||
if (!scene) return;
|
||||
sceneToBindAgent = scene;
|
||||
bindAgentDialogOpen = true;
|
||||
}
|
||||
|
||||
function handleRequestRename(id: string) {
|
||||
// Unified rename path: scroll the carousel to the scene header
|
||||
|
|
@ -389,6 +405,8 @@
|
|||
onConfirm={handleConfirmDeleteScene}
|
||||
onCancel={() => (sceneToDelete = null)}
|
||||
/>
|
||||
|
||||
<BindAgentDialog bind:scene={sceneToBindAgent} bind:open={bindAgentDialogOpen} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue