Fullfører oppgave 18.6: Egendefinerte AI-presets

Brukere kan nå opprette egne AI-preset-noder med custom prompt,
dele dem med samlinger/team via shared_with-edges, og redigere/slette
egne presets. Modellprofil (flash/standard) er låst — kun admin
kan oppgradere fra flash til standard.

Backend:
- POST /intentions/create_ai_preset: Oppretter custom preset med
  tvunget category=custom og model_profile=flash. Valgfri deling
  til samling i samme kall.
- update_node: Beskytter model_profile mot endring av ikke-admin.
  Forhindrer kategori-endring fra custom til standard.

Frontend:
- AiToolPanel: "+ Nytt preset"-knapp, opprett/rediger-skjema med
  tittel, prompt, retning, ikon og farge. Rediger/slett/del-knapper
  for egne custom presets. Egendefinerte presets markert med "egn."
- createAiPreset() i api.ts.

Dokumentasjon:
- ai_verktoy.md: Oppdatert fasestatus, ny § 10 om egendefinerte
  presets med API-eksempler og modellprofil-beskyttelse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 07:11:34 +00:00
parent d8897bf9b9
commit 8b5425fb59
6 changed files with 666 additions and 29 deletions

View file

@ -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": "<uuid>" // 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

View file

@ -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<CreateAiPresetResponse> {
return post(accessToken, '/intentions/create_ai_preset', data);
}
/** Hent ressursforbruk for en spesifikk node (kun eier). */
export async function fetchNodeUsage(
accessToken: string,

View file

@ -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<string | null>(null);
// --- Custom preset form state ---
let showCreateForm = $state(false);
let editingPresetId = $state<string | null>(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<string | null>(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<string, unknown> {
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, unknown>): string {
const icon = meta.icon as string;
const iconMap: Record<string, string> = {
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;
}
}
</script>
<section class="rounded-lg border border-gray-200 bg-white shadow-sm" data-component="ai-tool-panel">
@ -217,9 +373,18 @@
<div class="p-4 space-y-4">
<!-- Prompt-velger (presets er draggable for tool_to_node) -->
<div>
<label for="ai-preset-select" class="block text-xs font-medium text-gray-500 mb-1">
Velg verktøy
</label>
<div class="flex items-center justify-between mb-1">
<label for="ai-preset-select" class="block text-xs font-medium text-gray-500">
Velg verktøy
</label>
<button
onclick={openCreateForm}
class="text-xs text-purple-600 hover:text-purple-800 font-medium"
title="Opprett eget AI-verktøy"
>
+ Nytt preset
</button>
</div>
{#if presets.length === 0}
<p class="text-xs text-gray-400">Ingen AI-presets tilgjengelig.</p>
{:else}
@ -239,6 +404,9 @@
>
<span class="text-sm shrink-0">{presetIcon(meta)}</span>
<span class="truncate">{preset.title}</span>
{#if meta.category === 'custom'}
<span class="ml-auto text-[10px] text-gray-400 shrink-0">egn.</span>
{/if}
</button>
{/each}
</div>
@ -249,8 +417,43 @@
<!-- Valgt preset-detaljer -->
{#if selectedPreset && selectedMeta}
<div class="rounded-lg bg-gray-50 p-3 text-xs text-gray-600">
<p class="font-medium text-gray-700 mb-1">{selectedPreset.title}</p>
<p class="line-clamp-2">{selectedMeta.prompt}</p>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-700 mb-1">{selectedPreset.title}</p>
<p class="line-clamp-2">{selectedMeta.prompt}</p>
</div>
{#if isOwnCustomPreset}
<div class="flex items-center gap-1 shrink-0">
<button
onclick={() => openEditForm(selectedPreset!)}
class="rounded p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50"
title="Rediger preset"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button
onclick={() => openShareDialog(selectedPreset!.id)}
class="rounded p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50"
title="Del med samling/team"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<button
onclick={() => { if (confirm('Slette dette presetet?')) deletePreset(selectedPreset!.id); }}
class="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50"
title="Slett preset"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/if}
</div>
<div class="mt-2 flex items-center gap-3 text-gray-400">
<span>Modell: {modelLabel}</span>
<span>Retning: {
@ -262,6 +465,139 @@
</div>
{/if}
<!-- Create/Edit preset form -->
{#if showCreateForm}
<div class="rounded-lg border border-purple-200 bg-purple-50/50 p-4 space-y-3">
<h4 class="text-xs font-semibold text-purple-700">
{editingPresetId ? 'Rediger preset' : 'Opprett nytt AI-preset'}
</h4>
<div>
<label for="preset-title" class="block text-xs font-medium text-gray-600 mb-0.5">Tittel</label>
<input
id="preset-title"
type="text"
bind:value={formTitle}
placeholder="F.eks. 'Lag ingress'"
class="w-full rounded border border-gray-200 px-2.5 py-1.5 text-sm focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-200"
/>
</div>
<div>
<label for="preset-prompt" class="block text-xs font-medium text-gray-600 mb-0.5">Prompt</label>
<textarea
id="preset-prompt"
bind:value={formPrompt}
placeholder="Beskriv hva AI-et skal gjøre med teksten..."
rows="3"
class="w-full rounded border border-gray-200 px-2.5 py-1.5 text-sm focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-200 resize-none"
></textarea>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label for="preset-direction" class="block text-xs font-medium text-gray-600 mb-0.5">Retning</label>
<select
id="preset-direction"
bind:value={formDirection}
class="w-full rounded border border-gray-200 px-2 py-1.5 text-xs focus:border-purple-400 focus:outline-none"
>
<option value="node_to_tool">Ny node</option>
<option value="tool_to_node">In-place</option>
<option value="both">Begge</option>
</select>
</div>
<div>
<label for="preset-icon" class="block text-xs font-medium text-gray-600 mb-0.5">Ikon</label>
<select
id="preset-icon"
bind:value={formIcon}
class="w-full rounded border border-gray-200 px-2 py-1.5 text-xs focus:border-purple-400 focus:outline-none"
>
{#each ICON_OPTIONS as opt}
<option value={opt.key}>{opt.emoji} {opt.label}</option>
{/each}
</select>
</div>
<div>
<label for="preset-color" class="block text-xs font-medium text-gray-600 mb-0.5">Farge</label>
<input
id="preset-color"
type="color"
bind:value={formColor}
class="w-full h-8 rounded border border-gray-200 cursor-pointer"
/>
</div>
</div>
<p class="text-[10px] text-gray-400">
Modellprofil settes automatisk til Flash (rask). Admin kan oppgradere til Standard.
</p>
{#if formError}
<p class="text-xs text-red-600">{formError}</p>
{/if}
<div class="flex items-center justify-end gap-2">
<button
onclick={closeForm}
class="rounded border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-100"
>
Avbryt
</button>
<button
onclick={savePreset}
disabled={formSaving}
class="rounded bg-purple-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-purple-700 disabled:opacity-50"
>
{formSaving ? 'Lagrer...' : editingPresetId ? 'Oppdater' : 'Opprett'}
</button>
</div>
</div>
{/if}
<!-- Share dialog -->
{#if showShareDialog}
<div class="rounded-lg border border-blue-200 bg-blue-50/50 p-4 space-y-3">
<h4 class="text-xs font-semibold text-blue-700">Del preset med samling/team</h4>
<p class="text-[10px] text-gray-500">
Lim inn UUID for samlingen du vil dele med. Alle medlemmer av samlingen vil se presetet.
</p>
<div>
<label for="share-collection" class="block text-xs font-medium text-gray-600 mb-0.5">Samlings-ID</label>
<input
id="share-collection"
type="text"
bind:value={shareCollectionId}
placeholder="UUID for samling..."
class="w-full rounded border border-gray-200 px-2.5 py-1.5 text-sm font-mono focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-200"
/>
</div>
{#if shareError}
<p class="text-xs text-red-600">{shareError}</p>
{/if}
<div class="flex items-center justify-end gap-2">
<button
onclick={closeShareDialog}
class="rounded border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-100"
>
Avbryt
</button>
<button
onclick={sharePreset}
disabled={shareSaving}
class="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{shareSaving ? 'Deler...' : 'Del'}
</button>
</div>
</div>
{/if}
<!-- Fritekst-felt for egendefinert prompt -->
<div>
<label for="custom-prompt" class="block text-xs font-medium text-gray-500 mb-1">

View file

@ -952,6 +952,16 @@ pub async fn update_node(
.and_then(|p| p.get("theme_config"))
.cloned();
// Hent gamle AI-preset-verdier før existing.metadata flyttes (oppgave 18.6)
let old_model_profile = existing.metadata
.get("model_profile")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let old_category = existing.metadata
.get("category")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let metadata = req.metadata.unwrap_or(existing.metadata);
// -- Valider traits for samlingsnoder (oppgave 13.1) --
@ -960,6 +970,48 @@ pub async fn update_node(
// -- Valider metadata for AI-presets (oppgave 18.1) --
validate_ai_preset_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
// -- Beskytt model_profile for egendefinerte AI-presets (oppgave 18.6) --
// Kun admin/owner på en samling kan endre model_profile. Vanlige brukere
// som eier et custom preset kan redigere alt annet (prompt, icon, farge osv.)
if node_kind == "ai_preset" {
let new_profile = metadata.get("model_profile").and_then(|v| v.as_str()).map(|s| s.to_string());
if new_profile != old_model_profile {
// Sjekk om brukeren er admin (har owner-edge til en samling)
let is_admin_user = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM edges
WHERE source_id = $1
AND edge_type IN ('owner', 'admin')
AND target_id IN (SELECT id FROM nodes WHERE node_kind = 'collection')
)
"#,
)
.bind(user.node_id)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG-feil ved admin-sjekk for model_profile");
internal_error("Databasefeil")
})?;
if !is_admin_user {
return Err(forbidden(
"Kun admin kan endre modellprofil for AI-presets",
));
}
}
// Forhindre at vanlige brukere endrer category fra custom til standard
let new_cat = metadata.get("category").and_then(|v| v.as_str()).map(|s| s.to_string());
if new_cat != old_category && new_cat.as_deref() == Some("standard") {
return Err(forbidden(
"Kan ikke endre kategori til 'standard' — det er reservert for systempresets",
));
}
}
// -- Sjekk om custom_domain er endret (for re-rendering) --
let new_domain = metadata
.get("traits")
@ -3356,6 +3408,189 @@ pub async fn ai_process(
Ok(Json(AiProcessResponse { job_id }))
}
// =============================================================================
// POST /intentions/create_ai_preset — opprett egendefinert AI-preset
// =============================================================================
#[derive(Deserialize)]
pub struct CreateAiPresetRequest {
/// Visningstittel for presetet.
pub title: String,
/// Systemprompt som brukes ved AI-prosessering.
pub prompt: String,
/// Standard retning: "node_to_tool", "tool_to_node" eller "both".
pub default_direction: String,
/// Ikon-nøkkel (f.eks. "sparkles", "pencil_square").
pub icon: String,
/// Hex-farge (#RRGGBB).
pub color: String,
/// Valgfri: samlings-ID å dele presetet med (oppretter shared_with-edge).
pub share_with_collection_id: Option<Uuid>,
}
#[derive(Serialize)]
pub struct CreateAiPresetResponse {
pub node_id: Uuid,
/// Edge-ID for shared_with-edge (kun når share_with_collection_id er satt).
#[serde(skip_serializing_if = "Option::is_none")]
pub shared_edge_id: Option<Uuid>,
}
/// POST /intentions/create_ai_preset
///
/// Oppretter en egendefinert AI-preset-node. Setter alltid `category: "custom"`
/// og `model_profile: "flash"`. Kun admin kan endre modellprofil etterpå
/// (via update_node).
///
/// Kan valgfritt dele med en samling via `share_with_collection_id`.
///
/// Ref: docs/features/ai_verktoy.md § "Fase C: Egendefinerte prompter"
pub async fn create_ai_preset(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateAiPresetRequest>,
) -> Result<Json<CreateAiPresetResponse>, (StatusCode, Json<ErrorResponse>)> {
// Valider input
if req.title.trim().is_empty() {
return Err(bad_request("Tittel kan ikke være tom"));
}
if req.prompt.trim().is_empty() {
return Err(bad_request("Prompt kan ikke være tom"));
}
// Bygg metadata — alltid custom kategori og flash modellprofil
let metadata = serde_json::json!({
"prompt": req.prompt.trim(),
"model_profile": "flash",
"category": "custom",
"default_direction": req.default_direction,
"icon": req.icon,
"color": req.color
});
// Valider metadata med eksisterende validator
validate_ai_preset_metadata("ai_preset", &metadata).map_err(|e| bad_request(&e))?;
// Valider share_with_collection_id hvis satt
if let Some(col_id) = req.share_with_collection_id {
let col_exists: bool = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'collection')",
)
.bind(col_id)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG-feil ved sjekk av samling");
internal_error("Databasefeil")
})?;
if !col_exists {
return Err(bad_request("Samlingen finnes ikke"));
}
}
let node_id = Uuid::now_v7();
let node_id_str = node_id.to_string();
let created_by_str = user.node_id.to_string();
let metadata_str = metadata.to_string();
// Skriv til SpacetimeDB
state
.stdb
.create_node(
&node_id_str,
"ai_preset",
req.title.trim(),
"",
"discoverable",
&metadata_str,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_node (ai_preset)", e))?;
// Skriv til PostgreSQL
sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'ai_preset', $2, '', 'discoverable'::visibility, $3, $4)
"#,
)
.bind(node_id)
.bind(req.title.trim())
.bind(&metadata)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG insert ai_preset feilet");
internal_error("Kunne ikke opprette AI-preset")
})?;
// Opprett shared_with-edge hvis samlings-ID er satt
let shared_edge_id = if let Some(col_id) = req.share_with_collection_id {
let edge_id = Uuid::now_v7();
let edge_id_str = edge_id.to_string();
let col_id_str = col_id.to_string();
let empty_meta = serde_json::json!({}).to_string();
state
.stdb
.create_edge(
&edge_id_str,
&node_id_str,
&col_id_str,
"shared_with",
&empty_meta,
false,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_edge (shared_with)", e))?;
sqlx::query(
r#"
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'shared_with', '{}', false, $4)
"#,
)
.bind(edge_id)
.bind(node_id)
.bind(col_id)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG insert shared_with-edge feilet");
internal_error("Kunne ikke dele AI-preset med samling")
})?;
tracing::info!(
preset_id = %node_id,
collection_id = %col_id,
edge_id = %edge_id,
"AI-preset delt med samling"
);
Some(edge_id)
} else {
None
};
tracing::info!(
node_id = %node_id,
title = %req.title,
user = %user.node_id,
shared = ?req.share_with_collection_id,
"Egendefinert AI-preset opprettet"
);
Ok(Json(CreateAiPresetResponse {
node_id,
shared_edge_id,
}))
}
// =============================================================================
// POST /intentions/generate_tts — tekst-til-tale via ElevenLabs
// =============================================================================

View file

@ -204,6 +204,7 @@ async fn main() {
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
.route("/intentions/summarize", post(intentions::summarize))
.route("/intentions/ai_process", post(intentions::ai_process))
.route("/intentions/create_ai_preset", post(intentions::create_ai_preset))
.route("/intentions/generate_tts", post(intentions::generate_tts))
.route("/intentions/join_communication", post(intentions::join_communication))
.route("/intentions/leave_communication", post(intentions::leave_communication))

View file

@ -206,8 +206,7 @@ Ref: `docs/features/ai_verktoy.md`, `docs/retninger/arbeidsflaten.md`
- [x] 18.3 Direction-logikk: `tool_to_node` → lagre original som revisjon, oppdater node content. `node_to_tool` → opprett ny node med AI-output, opprett `derived_from`-edge til kilde + `processed_by`-edge til AI-preset.
- [x] 18.4 AI-verktøy panel (frontend): Svelte-komponent for arbeidsflaten. Prompt-velger med standardprompter, fritekst-felt for egendefinert prompt, modell-indikator (readonly). Drag-and-drop mottak for tekstnoder.
- [x] 18.5 Drag-and-drop integrasjon: node → verktøy (ny node), verktøy → node (in-place revisjon). Drop-sone feedback med verktøyets farge. Inkompatibilitet for lyd/bilde-noder med forklaring.
- [~] 18.6 Egendefinerte presets: brukere kan opprette egne AI-preset-noder med custom prompt. Dele via edges til samling/team. Modellprofil satt av admin.
> Påbegynt: 2026-03-18T06:59
- [x] 18.6 Egendefinerte presets: brukere kan opprette egne AI-preset-noder med custom prompt. Dele via edges til samling/team. Modellprofil satt av admin.
## Fase 19: Arbeidsflaten — Spatial Canvas