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:
@@ -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) |
|
||||
|
||||
35
implementation-plans/issue-014.md
Normal file
35
implementation-plans/issue-014.md
Normal 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
|
||||
@@ -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>
|
||||
|
||||
116
src/lib/stores/presets.svelte.ts
Normal file
116
src/lib/stores/presets.svelte.ts
Normal 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();
|
||||
Reference in New Issue
Block a user