diff --git a/docs/features/ai_verktoy.md b/docs/features/ai_verktoy.md index ac43abd..47ba60b 100644 --- a/docs/features/ai_verktoy.md +++ b/docs/features/ai_verktoy.md @@ -226,21 +226,61 @@ Flere AI-verktøy i serie: dra output fra "Oversett" videre til - [x] AI-preset node-type (`node_kind: 'ai_preset'`) + metadata-skjema - [x] Standard-presets som seed-data (rens tekst, korrektør, oppsummering osv.) - [x] `POST /intentions/ai_process` endepunkt i maskinrommet -- [ ] Verktøy-panel UI med prompt-velger og modell-indikator -- [ ] Jobbkø-integrasjon med AI Gateway +- [x] Verktøy-panel UI med prompt-velger og modell-indikator +- [x] Jobbkø-integrasjon med AI Gateway ### Fase B: Drag-and-drop -- [ ] Node → verktøy: opprett ny node med `derived_from`-edge -- [ ] Verktøy → node: in-place revisjon med versjonering -- [ ] Drop-sone feedback (farge, inkompatibilitet) +- [x] Node → verktøy: opprett ny node med `derived_from`-edge +- [x] Verktøy → node: in-place revisjon med versjonering +- [x] Drop-sone feedback (farge, inkompatibilitet) - [ ] Visuell output: ny node animeres inn ved siden av verktøyet ### Fase C: Egendefinerte prompter -- [ ] Opprett egne AI-preset-noder -- [ ] Del via edges (samling/team) +- [x] Opprett egne AI-preset-noder (`POST /intentions/create_ai_preset`) +- [x] Del via edges (samling/team) — `shared_with`-edge til samling +- [x] Rediger og slett egne presets (via update_node/delete_node) +- [x] Modellprofil-beskyttelse: kun admin kan endre model_profile - [ ] Prompt Lab-integrasjon for testing -## 10. Instruks for Claude Code +## 10. Egendefinerte presets (oppgave 18.6) + +### 10.1 Opprett egne AI-presets +Brukere kan opprette egne AI-preset-noder via `POST /intentions/create_ai_preset`: + +```json +{ + "title": "Lag ingress", + "prompt": "Skriv en kort, fengende ingress for denne teksten...", + "default_direction": "node_to_tool", + "icon": "pencil_square", + "color": "#10B981", + "share_with_collection_id": "" // valgfri +} +``` + +Egendefinerte presets får alltid: +- `category: "custom"` (kan ikke endres til "standard") +- `model_profile: "flash"` (kan kun oppgraderes av admin) +- `visibility: "discoverable"` (synlig for brukere med tilgang) + +### 10.2 Deling via edges +Presets kan deles med samlinger/team via `shared_with`-edge: +- Enten direkte i `create_ai_preset` via `share_with_collection_id` +- Eller via `create_edge` med `edge_type: "shared_with"` + +### 10.3 Modellprofil-beskyttelse +- Vanlige brukere kan **ikke** endre `model_profile` — det er alltid "flash" for egne presets +- Admin (owner/admin-edge til en samling) kan oppgradere til "standard" via `update_node` +- Kategori "standard" er reservert for systempresets og kan ikke velges av brukere + +### 10.4 Frontend +AiToolPanel har: +- "+ Nytt preset"-knapp for å opprette egne presets +- Rediger/slett/del-knapper for egne custom presets +- Egendefinerte presets markert med "egn." i preset-velgeren +- Delingsdialog for å dele med samling via UUID + +## 11. Instruks for Claude Code - AI-behandling skjer **alltid** via maskinrommet, aldri direkte fra frontend - Modellprofil → LiteLLM-alias-mapping konfigureres i maskinrommet - Brukeren kan **ikke** velge modell — dette er en server-side beslutning diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6c2488c..ad45bed 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1214,6 +1214,32 @@ export function aiProcess( return post(accessToken, '/intentions/ai_process', data); } +// ============================================================================= +// Egendefinerte AI-presets (oppgave 18.6) +// ============================================================================= + +export interface CreateAiPresetRequest { + title: string; + prompt: string; + default_direction: 'node_to_tool' | 'tool_to_node' | 'both'; + icon: string; + color: string; + share_with_collection_id?: string; +} + +export interface CreateAiPresetResponse { + node_id: string; + shared_edge_id?: string; +} + +/** Opprett en egendefinert AI-preset (custom, model_profile=flash). */ +export function createAiPreset( + accessToken: string, + data: CreateAiPresetRequest +): Promise { + return post(accessToken, '/intentions/create_ai_preset', data); +} + /** Hent ressursforbruk for en spesifikk node (kun eier). */ export async function fetchNodeUsage( accessToken: string, diff --git a/frontend/src/lib/components/AiToolPanel.svelte b/frontend/src/lib/components/AiToolPanel.svelte index 0f2eb14..0730229 100644 --- a/frontend/src/lib/components/AiToolPanel.svelte +++ b/frontend/src/lib/components/AiToolPanel.svelte @@ -2,8 +2,12 @@ /** * AI-verktøy panel for arbeidsflaten. * - * Viser standardprompter (ai_preset-noder), fritekst-felt for egendefinert - * prompt, modell-indikator (readonly), og mottar tekstnoder via drag-and-drop. + * Viser standardprompter og egendefinerte presets (ai_preset-noder), + * fritekst-felt for egendefinert prompt, modell-indikator (readonly), + * og mottar tekstnoder via drag-and-drop. + * + * Oppgave 18.6: Brukere kan opprette egne AI-preset-noder med custom + * prompt. Dele via edges til samling/team. Modellprofil satt av admin. * * Retning bestemmes av interaksjon: * - Dra en node HIT → node_to_tool (ny node med AI-output) @@ -14,7 +18,7 @@ */ import type { Node } from '$lib/spacetime'; import { nodeStore } from '$lib/spacetime'; - import { aiProcess } from '$lib/api'; + import { aiProcess, createAiPreset, updateNode, deleteNode, createEdge } from '$lib/api'; import { getDragPayload, checkAiToolCompat, setDragPayload } from '$lib/transfer'; interface Props { @@ -33,6 +37,24 @@ let lastResult = $state<{ success: boolean; message: string } | null>(null); let droppedNodeId = $state(null); + // --- Custom preset form state --- + let showCreateForm = $state(false); + let editingPresetId = $state(null); + let formTitle = $state(''); + let formPrompt = $state(''); + let formDirection = $state<'node_to_tool' | 'tool_to_node' | 'both'>('node_to_tool'); + let formIcon = $state('sparkles'); + let formColor = $state('#8B5CF6'); + let formSaving = $state(false); + let formError = $state(''); + + // --- Share dialog state --- + let showShareDialog = $state(false); + let sharePresetId = $state(null); + let shareCollectionId = $state(''); + let shareError = $state(''); + let shareSaving = $state(false); + // --- Derived: AI-preset noder fra STDB --- const presets = $derived.by(() => { const all = nodeStore.byKind('ai_preset'); @@ -70,6 +92,12 @@ const droppedNode = $derived(droppedNodeId ? nodeStore.get(droppedNodeId) : null); + /** Sjekk om valgt preset er egendefinert og eiet av brukeren */ + const isOwnCustomPreset = $derived.by(() => { + if (!selectedPreset || !selectedMeta || !userId) return false; + return selectedMeta.category === 'custom' && selectedPreset.createdBy === userId; + }); + // --- Helpers --- function parseMetadata(node: Node): Record { try { @@ -79,19 +107,20 @@ } } + const ICON_OPTIONS: { key: string; emoji: string; label: string }[] = [ + { key: 'sparkles', emoji: '✨', label: 'Gnist' }, + { key: 'check_badge', emoji: '✅', label: 'Sjekk' }, + { key: 'document_text', emoji: '📄', label: 'Dokument' }, + { key: 'language', emoji: '🌐', label: 'Språk' }, + { key: 'pencil_square', emoji: '✏️', label: 'Blyant' }, + { key: 'list_bullet', emoji: '📋', label: 'Liste' }, + { key: 'arrow_path', emoji: '🔄', label: 'Piler' }, + { key: 'chat_bubble_left_right', emoji: '💬', label: 'Chat' }, + ]; + function presetIcon(meta: Record): string { const icon = meta.icon as string; - const iconMap: Record = { - sparkles: '✨', - check_badge: '✅', - document_text: '📄', - language: '🌐', - pencil_square: '✏️', - list_bullet: '📋', - arrow_path: '🔄', - chat_bubble_left_right: '💬' - }; - return iconMap[icon] ?? '🤖'; + return ICON_OPTIONS.find(o => o.key === icon)?.emoji ?? '🤖'; } // --- Drag-and-drop: receiving nodes --- @@ -201,6 +230,133 @@ droppedNodeId = null; lastResult = null; } + + // --- Custom preset CRUD --- + function openCreateForm() { + editingPresetId = null; + formTitle = ''; + formPrompt = ''; + formDirection = 'node_to_tool'; + formIcon = 'sparkles'; + formColor = '#8B5CF6'; + formError = ''; + showCreateForm = true; + } + + function openEditForm(preset: Node) { + const meta = parseMetadata(preset); + editingPresetId = preset.id; + formTitle = preset.title ?? ''; + formPrompt = (meta.prompt as string) ?? ''; + formDirection = (meta.default_direction as 'node_to_tool' | 'tool_to_node' | 'both') ?? 'node_to_tool'; + formIcon = (meta.icon as string) ?? 'sparkles'; + formColor = (meta.color as string) ?? '#8B5CF6'; + formError = ''; + showCreateForm = true; + } + + function closeForm() { + showCreateForm = false; + editingPresetId = null; + formError = ''; + } + + async function savePreset() { + if (!accessToken) return; + if (!formTitle.trim()) { formError = 'Tittel er påkrevd'; return; } + if (!formPrompt.trim()) { formError = 'Prompt er påkrevd'; return; } + + formSaving = true; + formError = ''; + + try { + if (editingPresetId) { + // Update existing preset + await updateNode(accessToken, { + node_id: editingPresetId, + title: formTitle.trim(), + metadata: { + prompt: formPrompt.trim(), + model_profile: 'flash', // Brukere kan ikke endre modellprofil + category: 'custom', + default_direction: formDirection, + icon: formIcon, + color: formColor + } + }); + lastResult = { success: true, message: 'Preset oppdatert.' }; + selectedPresetId = editingPresetId; + } else { + // Create new preset + const res = await createAiPreset(accessToken, { + title: formTitle.trim(), + prompt: formPrompt.trim(), + default_direction: formDirection, + icon: formIcon, + color: formColor + }); + lastResult = { success: true, message: 'Nytt preset opprettet.' }; + selectedPresetId = res.node_id; + } + closeForm(); + } catch (err) { + formError = err instanceof Error ? err.message : String(err); + } finally { + formSaving = false; + } + } + + async function deletePreset(presetId: string) { + if (!accessToken) return; + try { + await deleteNode(accessToken, presetId); + if (selectedPresetId === presetId) selectedPresetId = null; + lastResult = { success: true, message: 'Preset slettet.' }; + } catch (err) { + lastResult = { + success: false, + message: `Kunne ikke slette: ${err instanceof Error ? err.message : String(err)}` + }; + } + } + + // --- Sharing --- + function openShareDialog(presetId: string) { + sharePresetId = presetId; + shareCollectionId = ''; + shareError = ''; + showShareDialog = true; + } + + function closeShareDialog() { + showShareDialog = false; + sharePresetId = null; + } + + async function sharePreset() { + if (!accessToken || !sharePresetId || !shareCollectionId.trim()) { + shareError = 'Samlings-ID er påkrevd'; + return; + } + + shareSaving = true; + shareError = ''; + + try { + await createEdge(accessToken, { + source_id: sharePresetId, + target_id: shareCollectionId.trim(), + edge_type: 'shared_with', + metadata: {} + }); + lastResult = { success: true, message: 'Preset delt med samling.' }; + closeShareDialog(); + } catch (err) { + shareError = err instanceof Error ? err.message : String(err); + } finally { + shareSaving = false; + } + }
@@ -217,9 +373,18 @@
- +
+ + +
{#if presets.length === 0}

Ingen AI-presets tilgjengelig.

{:else} @@ -239,6 +404,9 @@ > {presetIcon(meta)} {preset.title} + {#if meta.category === 'custom'} + egn. + {/if} {/each}
@@ -249,8 +417,43 @@ {#if selectedPreset && selectedMeta}
-

{selectedPreset.title}

-

{selectedMeta.prompt}

+
+
+

{selectedPreset.title}

+

{selectedMeta.prompt}

+
+ {#if isOwnCustomPreset} +
+ + + +
+ {/if} +
Modell: {modelLabel} Retning: { @@ -262,6 +465,139 @@
{/if} + + {#if showCreateForm} +
+

+ {editingPresetId ? 'Rediger preset' : 'Opprett nytt AI-preset'} +

+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +

+ Modellprofil settes automatisk til Flash (rask). Admin kan oppgradere til Standard. +

+ + {#if formError} +

{formError}

+ {/if} + +
+ + +
+
+ {/if} + + + {#if showShareDialog} +
+

Del preset med samling/team

+

+ Lim inn UUID for samlingen du vil dele med. Alle medlemmer av samlingen vil se presetet. +

+
+ + +
+ + {#if shareError} +

{shareError}

+ {/if} + +
+ + +
+
+ {/if} +