synops/frontend/src/routes/collection/[id]/+page.svelte
vegard d4de8b3619 Fullfører oppgave 18.4: AI-verktøy panel (frontend)
Svelte-komponent for AI-prosessering i arbeidsflaten:
- AiToolPanel.svelte: Viser ai_preset-noder fra STDB som velgbare
  verktøy, med modell-indikator (flash/standard) og prompt-forhåndsvisning
- Drag-and-drop mottak for tekstnoder med visuell feedback
- Validerer kompatibilitet (kun content/communication-noder)
- Kaller /intentions/ai_process via ny aiProcess()-funksjon i api.ts
- Integrert i collection-siden, tilgjengelig for alle innloggede brukere
- EditorTrait: innholdsnoder er nå draggable for AI-prosessering
- Fritekst-felt for egendefinert instruksjon (backend-støtte kommer i 18.6)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 06:50:29 +00:00

199 lines
7.8 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime';
import type { Node } from '$lib/spacetime';
// Trait components
import EditorTrait from '$lib/components/traits/EditorTrait.svelte';
import ChatTrait from '$lib/components/traits/ChatTrait.svelte';
import KanbanTrait from '$lib/components/traits/KanbanTrait.svelte';
import PodcastTrait from '$lib/components/traits/PodcastTrait.svelte';
import PublishingTrait from '$lib/components/traits/PublishingTrait.svelte';
import RssTrait from '$lib/components/traits/RssTrait.svelte';
import CalendarTrait from '$lib/components/traits/CalendarTrait.svelte';
import RecordingTrait from '$lib/components/traits/RecordingTrait.svelte';
import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte';
import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
import NodeUsage from '$lib/components/NodeUsage.svelte';
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
let showTraitAdmin = $state(false);
const connected = $derived(connectionState.current === 'connected');
const collectionId = $derived($page.params.id ?? '');
const collectionNode = $derived(connected ? nodeStore.get(collectionId) : undefined);
/** Parse full metadata */
const parsedMetadata = $derived.by((): Record<string, unknown> => {
if (!collectionNode) return {};
try {
return JSON.parse(collectionNode.metadata ?? '{}') as Record<string, unknown>;
} catch { return {}; }
});
/** Parse traits from collection metadata */
const traits = $derived.by((): Record<string, Record<string, unknown>> => {
const meta = parsedMetadata;
if (meta.traits && typeof meta.traits === 'object' && !Array.isArray(meta.traits)) {
return meta.traits as Record<string, Record<string, unknown>>;
}
return {};
});
const traitNames = $derived(Object.keys(traits));
/** Traits with dedicated components */
const knownTraits = new Set([
'editor', 'chat', 'kanban', 'podcast', 'publishing',
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer'
]);
/** Traits that have a dedicated component */
const renderedTraits = $derived(traitNames.filter(t => knownTraits.has(t)));
/** Traits without a dedicated component — shown with generic panel */
const genericTraits = $derived(traitNames.filter(t => !knownTraits.has(t)));
/** Count of child nodes */
const childCount = $derived.by(() => {
if (!connected || !collectionId) return 0;
let count = 0;
for (const edge of edgeStore.byTarget(collectionId)) {
if (edge.edgeType === 'belongs_to') count++;
}
return count;
});
</script>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a>
<h1 class="text-lg font-semibold text-gray-900">
{collectionNode?.title || 'Samling'}
</h1>
</div>
<div class="flex items-center gap-3">
{#if connected}
<span class="text-xs text-green-600">Tilkoblet</span>
{:else}
<span class="text-xs text-gray-400">{connectionState.current}</span>
{/if}
{#if traitNames.length > 0}
<span class="text-xs text-gray-400">{traitNames.length} traits</span>
{/if}
<span class="text-xs text-gray-400">{childCount} noder</span>
{#if connected && collectionNode && accessToken}
<button
onclick={() => { showTraitAdmin = !showTraitAdmin; }}
class="rounded-lg px-2 py-1 text-xs font-medium transition-colors {showTraitAdmin ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
Traits
</button>
{/if}
</div>
</div>
</header>
<main class="mx-auto max-w-4xl px-4 py-6">
<!-- Trait admin panel -->
{#if showTraitAdmin && accessToken}
<div class="mb-6">
<TraitAdmin
{accessToken}
collectionId={collectionId}
{traits}
metadata={parsedMetadata}
onclose={() => { showTraitAdmin = false; }}
/>
</div>
{/if}
{#if !connected}
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
{:else if !collectionNode}
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
<p class="font-medium">Samling ikke funnet</p>
<p class="mt-1">Samlingsnoden med ID {collectionId} finnes ikke eller er ikke tilgjengelig.</p>
<a href="/" class="mt-2 inline-block text-blue-600 hover:underline">Tilbake til mottak</a>
</div>
{:else if traitNames.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-6 text-center">
<p class="text-sm text-gray-500">Denne samlingen har ingen aktive traits.</p>
<p class="mt-1 text-xs text-gray-400">Traits bestemmer hvilke verktøy og visninger som er tilgjengelige.</p>
{#if accessToken && !showTraitAdmin}
<button
onclick={() => { showTraitAdmin = true; }}
class="mt-3 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
>
Legg til traits
</button>
{/if}
</div>
{:else}
<!-- Active traits as pills -->
<div class="mb-6 flex flex-wrap gap-2">
{#each traitNames as trait (trait)}
<span class="rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700">
{trait}
</span>
{/each}
</div>
<!-- Trait panels -->
<div class="space-y-4">
{#each renderedTraits as trait (trait)}
{#if trait === 'editor'}
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
{:else if trait === 'chat'}
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'kanban'}
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'podcast'}
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'publishing'}
<PublishingTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'rss'}
<RssTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'calendar'}
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'recording'}
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{:else if trait === 'transcription'}
<TranscriptionTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'studio'}
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'mixer'}
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{/if}
{/each}
{#each genericTraits as trait (trait)}
<GenericTrait name={trait} config={traits[trait]} />
{/each}
</div>
{/if}
<!-- AI-verktøy panel -->
{#if connected && accessToken}
<div class="mt-6">
<AiToolPanel {accessToken} userId={nodeId} />
</div>
{/if}
<!-- Ressursforbruk for denne noden (oppgave 15.9) -->
{#if accessToken && collectionId}
<div class="mt-6">
<NodeUsage nodeId={collectionId} {accessToken} />
</div>
{/if}
</main>
</div>