feat: add preset configurations with built-in and custom presets

Add preset store with three built-in presets (Strict mode, Research only,
Full access) and localStorage persistence for custom presets. Integrate
preset selector into ConfigSidebar with load, save, and delete actions.
Built-in presets cannot be deleted.

Closes #14

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-12 12:07:50 +01:00
parent a14a92a87d
commit 4bd1cef1cf
4 changed files with 253 additions and 0 deletions

View File

@@ -15,3 +15,4 @@
| #11 | Session creation and ID management | COMPLETED | [issue-011.md](issue-011.md) |
| #12 | Session history sidebar | COMPLETED | [issue-012.md](issue-012.md) |
| #13 | Session config sidebar component | COMPLETED | [issue-013.md](issue-013.md) |
| #14 | Preset configurations | COMPLETED | [issue-014.md](issue-014.md) |

View File

@@ -0,0 +1,35 @@
---
---
# Issue #14: Preset configurations
**Status:** COMPLETED
**Issue:** https://git.shahondin1624.de/llm-multiverse/llm-multiverse-ui/issues/14
**Branch:** `feature/issue-14-preset-configs`
## Acceptance Criteria
- [x] Save current config as a named preset
- [x] Load preset from a list
- [x] Delete custom presets
- [x] 2-3 built-in default presets shipped:
- "Strict mode" — no overrides, restricted tools (FS Write, Run Shell, Run Code, Package Install disabled)
- "Research only" — limited tool set for read-only operations (Memory Write, FS Write, Run Code, Run Shell, Package Install disabled)
- "Full access" — all overrides relaxed (OverrideLevel.ALL), no tools disabled
- [x] Presets persisted in localStorage
- [x] Built-in presets cannot be deleted
## Implementation
### New Files
- `src/lib/stores/presets.svelte.ts` — preset store with built-in presets and localStorage persistence for custom presets
### Modified Files
- `src/lib/components/ConfigSidebar.svelte` — added preset selector section at the top with load, save, and delete functionality
### Key Decisions
- Built-in presets are hardcoded constants, not stored in localStorage
- Custom presets stored under `llm-multiverse-presets` localStorage key
- Overwriting a custom preset with the same name is allowed; overwriting built-in presets is not
- Preset config uses a plain interface (`PresetConfig`) rather than protobuf types for clean serialization
- Preset section placed at the top of the sidebar before Override Level, matching the issue instructions

View File

@@ -3,6 +3,7 @@
import type { SessionConfig } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import { SessionConfigSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import { create } from '@bufbuild/protobuf';
import { presetStore } from '$lib/stores/presets.svelte';
let {
config,
@@ -10,6 +11,9 @@
}: { config: SessionConfig; onConfigChange: (config: SessionConfig) => void } = $props();
let newPermission = $state('');
let newPresetName = $state('');
let showSavePreset = $state(false);
let saveError = $state('');
const toolTypes = [
{ value: ToolType.MEMORY_READ, label: 'Memory Read' },
@@ -79,6 +83,36 @@
function resetConfig() {
onConfigChange(create(SessionConfigSchema, { overrideLevel: OverrideLevel.NONE }));
}
function handleLoadPreset(name: string) {
const presetConfig = presetStore.loadPreset(name);
if (!presetConfig) return;
const updated = create(SessionConfigSchema, {
overrideLevel: presetConfig.overrideLevel,
disabledTools: [...presetConfig.disabledTools],
grantedPermissions: [...presetConfig.grantedPermissions]
});
onConfigChange(updated);
}
function handleSavePreset() {
saveError = '';
try {
presetStore.savePreset(newPresetName, {
overrideLevel: config.overrideLevel,
disabledTools: [...config.disabledTools],
grantedPermissions: [...config.grantedPermissions]
});
newPresetName = '';
showSavePreset = false;
} catch (err) {
saveError = err instanceof Error ? err.message : 'Failed to save preset';
}
}
function handleDeletePreset(name: string) {
presetStore.deletePreset(name);
}
</script>
<aside class="flex h-full w-72 flex-col border-l border-gray-200 bg-white">
@@ -99,6 +133,73 @@
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-5">
<!-- Presets -->
<div>
<p class="mb-2 text-xs font-medium text-gray-700">Presets</p>
<div class="space-y-1">
{#each presetStore.getAllPresets() as preset (preset.name)}
<div class="flex items-center gap-1">
<button
type="button"
onclick={() => handleLoadPreset(preset.name)}
class="flex-1 rounded-lg px-3 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-50"
>
{preset.name}
{#if preset.builtIn}
<span class="text-[10px] text-gray-400">built-in</span>
{/if}
</button>
{#if !preset.builtIn}
<button
type="button"
onclick={() => handleDeletePreset(preset.name)}
class="rounded px-1.5 py-1 text-xs text-gray-400 hover:text-red-500"
>
</button>
{/if}
</div>
{/each}
</div>
{#if showSavePreset}
<div class="mt-2 flex gap-1">
<input
type="text"
bind:value={newPresetName}
onkeydown={(e) => { if (e.key === 'Enter') handleSavePreset(); }}
placeholder="Preset name"
class="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs focus:border-blue-500 focus:outline-none"
/>
<button
type="button"
onclick={handleSavePreset}
disabled={!newPresetName.trim()}
class="rounded-lg bg-blue-50 px-2 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 disabled:opacity-50"
>
Save
</button>
<button
type="button"
onclick={() => { showSavePreset = false; saveError = ''; newPresetName = ''; }}
class="rounded-lg px-1.5 py-1.5 text-xs text-gray-400 hover:text-gray-600"
>
</button>
</div>
{#if saveError}
<p class="mt-1 text-xs text-red-500">{saveError}</p>
{/if}
{:else}
<button
type="button"
onclick={() => (showSavePreset = true)}
class="mt-2 w-full rounded-lg border border-dashed border-gray-300 px-3 py-1.5 text-xs text-gray-500 hover:border-gray-400 hover:text-gray-700"
>
Save current as preset
</button>
{/if}
</div>
<!-- Override Level -->
<div>
<p class="mb-2 text-xs font-medium text-gray-700">Override Level</p>

View File

@@ -0,0 +1,116 @@
import { OverrideLevel } from '$lib/proto/llm_multiverse/v1/common_pb';
export interface PresetConfig {
overrideLevel: OverrideLevel;
disabledTools: string[];
grantedPermissions: string[];
}
export interface Preset {
name: string;
config: PresetConfig;
builtIn: boolean;
}
const STORAGE_KEY = 'llm-multiverse-presets';
const builtInPresets: Preset[] = [
{
name: 'Strict mode',
config: {
overrideLevel: OverrideLevel.NONE,
disabledTools: ['FS Write', 'Run Shell', 'Run Code', 'Package Install'],
grantedPermissions: []
},
builtIn: true
},
{
name: 'Research only',
config: {
overrideLevel: OverrideLevel.NONE,
disabledTools: ['Memory Write', 'FS Write', 'Run Code', 'Run Shell', 'Package Install'],
grantedPermissions: []
},
builtIn: true
},
{
name: 'Full access',
config: {
overrideLevel: OverrideLevel.ALL,
disabledTools: [],
grantedPermissions: []
},
builtIn: true
}
];
function loadCustomPresets(): Preset[] {
if (typeof localStorage === 'undefined') return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed: Preset[] = JSON.parse(raw);
return parsed.map((p) => ({ ...p, builtIn: false }));
} catch {
return [];
}
}
function saveCustomPresets(presets: Preset[]) {
if (typeof localStorage === 'undefined') return;
const custom = presets.filter((p) => !p.builtIn);
localStorage.setItem(STORAGE_KEY, JSON.stringify(custom));
}
function createPresetStore() {
const customPresets = $state<Preset[]>(loadCustomPresets());
function getAllPresets(): Preset[] {
return [...builtInPresets, ...customPresets];
}
function savePreset(name: string, config: PresetConfig): Preset {
const trimmed = name.trim();
if (!trimmed) throw new Error('Preset name cannot be empty');
if (builtInPresets.some((p) => p.name === trimmed)) {
throw new Error('Cannot overwrite a built-in preset');
}
const existing = customPresets.findIndex((p) => p.name === trimmed);
const preset: Preset = { name: trimmed, config, builtIn: false };
if (existing >= 0) {
customPresets[existing] = preset;
} else {
customPresets.push(preset);
}
saveCustomPresets(customPresets);
return preset;
}
function loadPreset(name: string): PresetConfig | null {
const all = getAllPresets();
const preset = all.find((p) => p.name === name);
return preset?.config ?? null;
}
function deletePreset(name: string): boolean {
if (builtInPresets.some((p) => p.name === name)) return false;
const idx = customPresets.findIndex((p) => p.name === name);
if (idx < 0) return false;
customPresets.splice(idx, 1);
saveCustomPresets(customPresets);
return true;
}
return {
getAllPresets,
savePreset,
loadPreset,
deletePreset
};
}
export const presetStore = createPresetStore();