SpacetimeDB er nå helt fjernet fra Synops. Sanntid håndteres av PG LISTEN/NOTIFY + WebSocket i portvokteren (maskinrommet). Kode fjernet: - spacetimedb/ Rust-modul og spacetime.json - maskinrommet/src/stdb.rs (HTTP-klient for STDB-reducers) - frontend module_bindings/ (23 auto-genererte filer) - spacetimedb npm-avhengighet fra package.json - scripts/test-sanntid.sh (testet STDB-flyt) Infrastruktur: - Docker-container stoppet og fjernet fra docker-compose.yml - Caddy: fjernet /spacetime/* reverse proxy - maskinrommet-env.sh: fjernet STDB_IP og SPACETIMEDB_*-variabler - .env.example: fjernet SpacetimeDB-seksjoner Dokumentasjon oppdatert: - CLAUDE.md: stack, lagmodell, kjerneprinsipper, driftsmodell - docs/arkitektur.md: skrivestien, lesestien, datalag, teknologivalg - docs/retninger/datalaget.md: migrasjonshistorikk, status "fjernet" - 37 andre docs oppdatert (features, concepts, infra, ops, retninger) - Alle kode-kommentarer med STDB-referanser oppdatert Verifisert: maskinrommet bygger og starter OK, frontend bygger OK, helsesjekk returnerer 200. Caddy reloadet.
687 lines
23 KiB
Svelte
687 lines
23 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* AI-verktøy panel for arbeidsflaten.
|
|
*
|
|
* 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)
|
|
* - Dra et preset PÅ en node → tool_to_node (in-place revisjon)
|
|
*
|
|
* Drop-sonen bruker presetets farge for visuell feedback.
|
|
* Inkompatible noder (lyd, bilde) får rød sone med forklaring.
|
|
*/
|
|
import type { Node } from '$lib/spacetime';
|
|
import { nodeStore } from '$lib/spacetime';
|
|
import { aiProcess, createAiPreset, updateNode, deleteNode, createEdge } from '$lib/api';
|
|
import { getDragPayload, checkAiToolCompat, setDragPayload } from '$lib/transfer';
|
|
|
|
interface Props {
|
|
accessToken?: string;
|
|
userId?: string;
|
|
}
|
|
|
|
let { accessToken, userId }: Props = $props();
|
|
|
|
// --- State ---
|
|
let selectedPresetId = $state<string | null>(null);
|
|
let customPrompt = $state('');
|
|
let dragOver = $state(false);
|
|
let dragIncompat = $state<{ reason: string; suggestion?: string } | null>(null);
|
|
let processing = $state(false);
|
|
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 store ---
|
|
const presets = $derived.by(() => {
|
|
const all = nodeStore.byKind('ai_preset');
|
|
return all.sort((a, b) => {
|
|
const metaA = parseMetadata(a);
|
|
const metaB = parseMetadata(b);
|
|
const catA = metaA.category === 'standard' ? 0 : 1;
|
|
const catB = metaB.category === 'standard' ? 0 : 1;
|
|
if (catA !== catB) return catA - catB;
|
|
return (a.title ?? '').localeCompare(b.title ?? '', 'nb');
|
|
});
|
|
});
|
|
|
|
const selectedPreset = $derived(
|
|
selectedPresetId ? nodeStore.get(selectedPresetId) : null
|
|
);
|
|
|
|
const selectedMeta = $derived(selectedPreset ? parseMetadata(selectedPreset) : null);
|
|
|
|
/** The color of the selected preset, used for drop-zone feedback */
|
|
const presetColor = $derived((selectedMeta?.color as string) ?? '#9333ea');
|
|
|
|
const modelLabel = $derived.by(() => {
|
|
if (!selectedMeta) return '';
|
|
switch (selectedMeta.model_profile) {
|
|
case 'flash': return 'Rask (flash)';
|
|
case 'standard': return 'Standard';
|
|
default: return selectedMeta.model_profile ?? '';
|
|
}
|
|
});
|
|
|
|
const defaultDirection = $derived(
|
|
(selectedMeta?.default_direction as string) ?? 'node_to_tool'
|
|
);
|
|
|
|
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 {
|
|
return JSON.parse(node.metadata ?? '{}');
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
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;
|
|
return ICON_OPTIONS.find(o => o.key === icon)?.emoji ?? '🤖';
|
|
}
|
|
|
|
// --- Drag-and-drop: receiving nodes ---
|
|
function handleDragOver(e: DragEvent) {
|
|
e.preventDefault();
|
|
if (!e.dataTransfer) return;
|
|
|
|
// Check compatibility during drag-over for visual feedback
|
|
const payload = getDragPayload(e.dataTransfer);
|
|
if (payload) {
|
|
const node = nodeStore.get(payload.nodeId);
|
|
if (node) {
|
|
const compat = checkAiToolCompat(node.nodeKind, !!node.content?.trim());
|
|
if (!compat.compatible) {
|
|
dragIncompat = { reason: compat.reason!, suggestion: compat.suggestion };
|
|
e.dataTransfer.dropEffect = 'none';
|
|
dragOver = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
dragIncompat = null;
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
dragOver = true;
|
|
}
|
|
|
|
function handleDragLeave(e: DragEvent) {
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
const x = e.clientX;
|
|
const y = e.clientY;
|
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
|
dragOver = false;
|
|
dragIncompat = null;
|
|
}
|
|
}
|
|
|
|
function handleDrop(e: DragEvent) {
|
|
e.preventDefault();
|
|
dragOver = false;
|
|
dragIncompat = null;
|
|
|
|
if (!e.dataTransfer) return;
|
|
const payload = getDragPayload(e.dataTransfer);
|
|
if (!payload) return;
|
|
|
|
const node = nodeStore.get(payload.nodeId);
|
|
if (!node) return;
|
|
|
|
// Check compatibility
|
|
const compat = checkAiToolCompat(node.nodeKind, !!node.content?.trim());
|
|
if (!compat.compatible) {
|
|
lastResult = {
|
|
success: false,
|
|
message: [compat.reason, compat.suggestion].filter(Boolean).join(' ')
|
|
};
|
|
return;
|
|
}
|
|
|
|
droppedNodeId = payload.nodeId;
|
|
lastResult = null;
|
|
}
|
|
|
|
// --- Drag-and-drop: dragging presets OUT (for tool_to_node) ---
|
|
function handlePresetDragStart(e: DragEvent, presetId: string) {
|
|
if (!e.dataTransfer) return;
|
|
setDragPayload(e.dataTransfer, {
|
|
nodeId: presetId,
|
|
nodeKind: 'ai_preset',
|
|
presetId,
|
|
sourcePanel: 'ai_tool'
|
|
});
|
|
}
|
|
|
|
// --- Prosessering ---
|
|
async function processNode() {
|
|
if (!accessToken || !selectedPresetId || !droppedNodeId) return;
|
|
|
|
const direction = defaultDirection === 'both' ? 'node_to_tool' : defaultDirection;
|
|
if (direction !== 'node_to_tool' && direction !== 'tool_to_node') return;
|
|
|
|
processing = true;
|
|
lastResult = null;
|
|
|
|
try {
|
|
const res = await aiProcess(accessToken, {
|
|
source_node_id: droppedNodeId,
|
|
ai_preset_id: selectedPresetId,
|
|
direction
|
|
});
|
|
lastResult = {
|
|
success: true,
|
|
message: `Jobb opprettet (${res.job_id.slice(0, 8)}…). Resultatet dukker opp i sanntid.`
|
|
};
|
|
droppedNodeId = null;
|
|
} catch (err) {
|
|
lastResult = {
|
|
success: false,
|
|
message: `Feil: ${err instanceof Error ? err.message : String(err)}`
|
|
};
|
|
} finally {
|
|
processing = false;
|
|
}
|
|
}
|
|
|
|
function clearDropped() {
|
|
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">
|
|
<div class="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
|
<span class="text-lg" aria-hidden="true">🤖</span>
|
|
<h3 class="text-sm font-semibold text-gray-700">AI-verktøy</h3>
|
|
{#if modelLabel}
|
|
<span class="ml-auto rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-600">
|
|
{modelLabel}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="p-4 space-y-4">
|
|
<!-- Prompt-velger (presets er draggable for tool_to_node) -->
|
|
<div>
|
|
<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}
|
|
<div class="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
|
|
{#each presets as preset (preset.id)}
|
|
{@const meta = parseMetadata(preset)}
|
|
<button
|
|
onclick={() => { selectedPresetId = preset.id; }}
|
|
draggable="true"
|
|
ondragstart={(e) => handlePresetDragStart(e, preset.id)}
|
|
class="flex items-center gap-1.5 rounded-lg border px-2.5 py-2 text-left text-xs transition-colors cursor-grab active:cursor-grabbing
|
|
{selectedPresetId === preset.id
|
|
? 'border-purple-400 bg-purple-50 text-purple-800 shadow-sm'
|
|
: 'border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}"
|
|
style={selectedPresetId === preset.id && meta.color ? `border-color: ${meta.color}` : ''}
|
|
title="Klikk for å velge, eller dra til en tekstnode for in-place revisjon"
|
|
>
|
|
<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>
|
|
<p class="mt-1 text-xs text-gray-300">Dra et verktøy til en tekstnode for in-place revisjon</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- 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: {
|
|
defaultDirection === 'node_to_tool' ? 'Ny node'
|
|
: defaultDirection === 'tool_to_node' ? 'In-place'
|
|
: 'Begge'
|
|
}</span>
|
|
</div>
|
|
</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">
|
|
Egendefinert instruksjon (valgfritt)
|
|
</label>
|
|
<textarea
|
|
id="custom-prompt"
|
|
bind:value={customPrompt}
|
|
placeholder="Legg til spesifikke instruksjoner..."
|
|
rows="2"
|
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder:text-gray-300 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-200 resize-none"
|
|
></textarea>
|
|
<p class="mt-0.5 text-xs text-gray-400">
|
|
Kommer i tillegg til valgt preset (funksjon under utvikling).
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Drop-sone med dynamisk farge fra valgt preset -->
|
|
<div
|
|
role="region"
|
|
aria-label="Slipp tekstnode her"
|
|
ondragover={handleDragOver}
|
|
ondragleave={handleDragLeave}
|
|
ondrop={handleDrop}
|
|
class="rounded-lg border-2 border-dashed p-6 text-center transition-colors"
|
|
class:border-gray-200={!dragOver && !droppedNode}
|
|
class:bg-gray-50={!dragOver && !droppedNode}
|
|
class:border-red-400={dragOver && dragIncompat}
|
|
class:bg-red-50={dragOver && dragIncompat}
|
|
class:border-green-300={!dragOver && !!droppedNode}
|
|
class:bg-green-50={!dragOver && !!droppedNode}
|
|
style={dragOver && !dragIncompat ? `border-color: ${presetColor}; background-color: ${presetColor}11;` : ''}
|
|
>
|
|
{#if droppedNode}
|
|
<!-- Viser droppet node -->
|
|
<div class="space-y-2">
|
|
<p class="text-sm font-medium text-gray-700">
|
|
{droppedNode.title || 'Uten tittel'}
|
|
</p>
|
|
<p class="text-xs text-gray-500 line-clamp-2">
|
|
{droppedNode.content?.slice(0, 120)}{(droppedNode.content?.length ?? 0) > 120 ? '…' : ''}
|
|
</p>
|
|
<div class="flex items-center justify-center gap-2 pt-1">
|
|
<button
|
|
onclick={processNode}
|
|
disabled={!selectedPresetId || processing}
|
|
class="rounded-lg px-4 py-1.5 text-xs font-medium text-white transition-colors"
|
|
class:bg-gray-300={!selectedPresetId || processing}
|
|
class:cursor-not-allowed={!selectedPresetId || processing}
|
|
style={selectedPresetId && !processing ? `background-color: ${presetColor};` : ''}
|
|
>
|
|
{#if processing}
|
|
Prosesserer…
|
|
{:else}
|
|
Kjør {selectedPreset?.title ?? 'AI'}
|
|
{/if}
|
|
</button>
|
|
<button
|
|
onclick={clearDropped}
|
|
class="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-100"
|
|
>
|
|
Fjern
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else if dragOver && dragIncompat}
|
|
<!-- Inkompatibel node -->
|
|
<p class="text-sm text-red-600 font-medium">{dragIncompat.reason}</p>
|
|
{#if dragIncompat.suggestion}
|
|
<p class="text-xs text-red-400 mt-1">{dragIncompat.suggestion}</p>
|
|
{/if}
|
|
{:else if dragOver}
|
|
<p class="text-sm font-medium" style="color: {presetColor};">Slipp her for AI-prosessering</p>
|
|
{:else}
|
|
<p class="text-sm text-gray-400">Dra en tekstnode hit</p>
|
|
<p class="text-xs text-gray-300 mt-1">Støtter innholds- og kommunikasjonsnoder</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Resultat/feil-melding -->
|
|
{#if lastResult}
|
|
<div class="rounded-lg p-3 text-xs {lastResult.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}">
|
|
{lastResult.message}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</section>
|