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:
parent
d8897bf9b9
commit
8b5425fb59
6 changed files with 666 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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">
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue