Fullfører oppgave 18.5: Drag-and-drop integrasjon mellom verktøy
Implementerer toveis drag-and-drop mellom AI-verktøypanelet og innholdspaneler, med visuell feedback og kompatibilitetssjekking. Nye filer: - transfer.ts: Sentralisert transfer-tjeneste med kompatibilitetsmatrise, DragPayload-format (application/x-synops-transfer), og inkompatibilitets-meldinger for lyd/bilde-noder. Endringer: - AiToolPanel: Drop-sone bruker presetets farge (dynamisk border/bg), viser rød sone med forklaring ved inkompatible noder (lyd/bilde), presets er draggable ut (for tool_to_node retning). - EditorTrait: Aksepterer AI-preset drops på innholdsnoder (tool_to_node), viser visuell feedback (lilla=kompatibel, rød=inkompatibel), trigger in-place revisjon via aiProcess API. - ChatTrait: Kommunikasjonsnoder er nå draggable til AI-verktøyet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
999f46f15d
commit
35d39962dd
5 changed files with 393 additions and 83 deletions
|
|
@ -7,12 +7,15 @@
|
||||||
*
|
*
|
||||||
* Retning bestemmes av interaksjon:
|
* Retning bestemmes av interaksjon:
|
||||||
* - Dra en node HIT → node_to_tool (ny node med AI-output)
|
* - Dra en node HIT → node_to_tool (ny node med AI-output)
|
||||||
* - Dra verktøyet PÅ en node → tool_to_node (in-place revisjon)
|
* - Dra et preset PÅ en node → tool_to_node (in-place revisjon)
|
||||||
* For nå støtter panelet kun node_to_tool (drop-mottak).
|
*
|
||||||
|
* 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 type { Node } from '$lib/spacetime';
|
||||||
import { nodeStore } from '$lib/spacetime';
|
import { nodeStore } from '$lib/spacetime';
|
||||||
import { aiProcess } from '$lib/api';
|
import { aiProcess } from '$lib/api';
|
||||||
|
import { getDragPayload, checkAiToolCompat, setDragPayload } from '$lib/transfer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
|
@ -25,6 +28,7 @@
|
||||||
let selectedPresetId = $state<string | null>(null);
|
let selectedPresetId = $state<string | null>(null);
|
||||||
let customPrompt = $state('');
|
let customPrompt = $state('');
|
||||||
let dragOver = $state(false);
|
let dragOver = $state(false);
|
||||||
|
let dragIncompat = $state<{ reason: string; suggestion?: string } | null>(null);
|
||||||
let processing = $state(false);
|
let processing = $state(false);
|
||||||
let lastResult = $state<{ success: boolean; message: string } | null>(null);
|
let lastResult = $state<{ success: boolean; message: string } | null>(null);
|
||||||
let droppedNodeId = $state<string | null>(null);
|
let droppedNodeId = $state<string | null>(null);
|
||||||
|
|
@ -32,7 +36,6 @@
|
||||||
// --- Derived: AI-preset noder fra STDB ---
|
// --- Derived: AI-preset noder fra STDB ---
|
||||||
const presets = $derived.by(() => {
|
const presets = $derived.by(() => {
|
||||||
const all = nodeStore.byKind('ai_preset');
|
const all = nodeStore.byKind('ai_preset');
|
||||||
// Sorter: standard først, deretter custom, alfabetisk innenfor
|
|
||||||
return all.sort((a, b) => {
|
return all.sort((a, b) => {
|
||||||
const metaA = parseMetadata(a);
|
const metaA = parseMetadata(a);
|
||||||
const metaB = parseMetadata(b);
|
const metaB = parseMetadata(b);
|
||||||
|
|
@ -49,6 +52,9 @@
|
||||||
|
|
||||||
const selectedMeta = $derived(selectedPreset ? parseMetadata(selectedPreset) : 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(() => {
|
const modelLabel = $derived.by(() => {
|
||||||
if (!selectedMeta) return '';
|
if (!selectedMeta) return '';
|
||||||
switch (selectedMeta.model_profile) {
|
switch (selectedMeta.model_profile) {
|
||||||
|
|
@ -75,7 +81,6 @@
|
||||||
|
|
||||||
function presetIcon(meta: Record<string, unknown>): string {
|
function presetIcon(meta: Record<string, unknown>): string {
|
||||||
const icon = meta.icon as string;
|
const icon = meta.icon as string;
|
||||||
// Map heroicon names to unicode/emoji approximations
|
|
||||||
const iconMap: Record<string, string> = {
|
const iconMap: Record<string, string> = {
|
||||||
sparkles: '✨',
|
sparkles: '✨',
|
||||||
check_badge: '✅',
|
check_badge: '✅',
|
||||||
|
|
@ -89,56 +94,78 @@
|
||||||
return iconMap[icon] ?? '🤖';
|
return iconMap[icon] ?? '🤖';
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Drag-and-drop ---
|
// --- Drag-and-drop: receiving nodes ---
|
||||||
function handleDragOver(e: DragEvent) {
|
function handleDragOver(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer) {
|
if (!e.dataTransfer) return;
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
|
||||||
|
// 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;
|
dragOver = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragLeave(e: DragEvent) {
|
function handleDragLeave(e: DragEvent) {
|
||||||
// Only leave if actually leaving the drop zone
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
const x = e.clientX;
|
const x = e.clientX;
|
||||||
const y = e.clientY;
|
const y = e.clientY;
|
||||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||||
dragOver = false;
|
dragOver = false;
|
||||||
|
dragIncompat = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
function handleDrop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragOver = false;
|
dragOver = false;
|
||||||
|
dragIncompat = null;
|
||||||
|
|
||||||
const nodeId = e.dataTransfer?.getData('text/plain');
|
if (!e.dataTransfer) return;
|
||||||
if (!nodeId) return;
|
const payload = getDragPayload(e.dataTransfer);
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
const node = nodeStore.get(nodeId);
|
const node = nodeStore.get(payload.nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
// Sjekk kompatibilitet: kun tekstnoder
|
// Check compatibility
|
||||||
if (node.nodeKind !== 'content' && node.nodeKind !== 'communication') {
|
const compat = checkAiToolCompat(node.nodeKind, !!node.content?.trim());
|
||||||
|
if (!compat.compatible) {
|
||||||
lastResult = {
|
lastResult = {
|
||||||
success: false,
|
success: false,
|
||||||
message: `AI-verktøyet behandler kun tekst. "${node.title ?? 'Noden'}" er av type ${node.nodeKind}.`
|
message: [compat.reason, compat.suggestion].filter(Boolean).join(' ')
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node.content?.trim()) {
|
droppedNodeId = payload.nodeId;
|
||||||
lastResult = {
|
|
||||||
success: false,
|
|
||||||
message: 'Noden har ikke noe tekstinnhold å prosessere.'
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
droppedNodeId = nodeId;
|
|
||||||
lastResult = null;
|
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 ---
|
// --- Prosessering ---
|
||||||
async function processNode() {
|
async function processNode() {
|
||||||
if (!accessToken || !selectedPresetId || !droppedNodeId) return;
|
if (!accessToken || !selectedPresetId || !droppedNodeId) return;
|
||||||
|
|
@ -188,7 +215,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 space-y-4">
|
<div class="p-4 space-y-4">
|
||||||
<!-- Prompt-velger -->
|
<!-- Prompt-velger (presets er draggable for tool_to_node) -->
|
||||||
<div>
|
<div>
|
||||||
<label for="ai-preset-select" class="block text-xs font-medium text-gray-500 mb-1">
|
<label for="ai-preset-select" class="block text-xs font-medium text-gray-500 mb-1">
|
||||||
Velg verktøy
|
Velg verktøy
|
||||||
|
|
@ -201,17 +228,21 @@
|
||||||
{@const meta = parseMetadata(preset)}
|
{@const meta = parseMetadata(preset)}
|
||||||
<button
|
<button
|
||||||
onclick={() => { selectedPresetId = preset.id; }}
|
onclick={() => { selectedPresetId = preset.id; }}
|
||||||
class="flex items-center gap-1.5 rounded-lg border px-2.5 py-2 text-left text-xs transition-colors
|
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
|
{selectedPresetId === preset.id
|
||||||
? 'border-purple-400 bg-purple-50 text-purple-800 shadow-sm'
|
? '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'}"
|
: 'border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}"
|
||||||
style={selectedPresetId === preset.id ? `border-color: ${meta.color}` : ''}
|
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="text-sm shrink-0">{presetIcon(meta)}</span>
|
||||||
<span class="truncate">{preset.title}</span>
|
<span class="truncate">{preset.title}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-300">Dra et verktøy til en tekstnode for in-place revisjon</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -248,19 +279,21 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Drop-sone -->
|
<!-- Drop-sone med dynamisk farge fra valgt preset -->
|
||||||
<div
|
<div
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="Slipp tekstnode her"
|
aria-label="Slipp tekstnode her"
|
||||||
ondragover={handleDragOver}
|
ondragover={handleDragOver}
|
||||||
ondragleave={handleDragLeave}
|
ondragleave={handleDragLeave}
|
||||||
ondrop={handleDrop}
|
ondrop={handleDrop}
|
||||||
class="rounded-lg border-2 border-dashed p-6 text-center transition-colors
|
class="rounded-lg border-2 border-dashed p-6 text-center transition-colors"
|
||||||
{dragOver
|
class:border-gray-200={!dragOver && !droppedNode}
|
||||||
? 'border-purple-400 bg-purple-50'
|
class:bg-gray-50={!dragOver && !droppedNode}
|
||||||
: droppedNode
|
class:border-red-400={dragOver && dragIncompat}
|
||||||
? 'border-green-300 bg-green-50'
|
class:bg-red-50={dragOver && dragIncompat}
|
||||||
: 'border-gray-200 bg-gray-50'}"
|
class:border-green-300={!dragOver && !!droppedNode}
|
||||||
|
class:bg-green-50={!dragOver && !!droppedNode}
|
||||||
|
style={dragOver && !dragIncompat ? `border-color: ${presetColor}; background-color: ${presetColor}11;` : ''}
|
||||||
>
|
>
|
||||||
{#if droppedNode}
|
{#if droppedNode}
|
||||||
<!-- Viser droppet node -->
|
<!-- Viser droppet node -->
|
||||||
|
|
@ -275,10 +308,10 @@
|
||||||
<button
|
<button
|
||||||
onclick={processNode}
|
onclick={processNode}
|
||||||
disabled={!selectedPresetId || processing}
|
disabled={!selectedPresetId || processing}
|
||||||
class="rounded-lg px-4 py-1.5 text-xs font-medium text-white transition-colors
|
class="rounded-lg px-4 py-1.5 text-xs font-medium text-white transition-colors"
|
||||||
{!selectedPresetId || processing
|
class:bg-gray-300={!selectedPresetId || processing}
|
||||||
? 'bg-gray-300 cursor-not-allowed'
|
class:cursor-not-allowed={!selectedPresetId || processing}
|
||||||
: 'bg-purple-600 hover:bg-purple-700'}"
|
style={selectedPresetId && !processing ? `background-color: ${presetColor};` : ''}
|
||||||
>
|
>
|
||||||
{#if processing}
|
{#if processing}
|
||||||
Prosesserer…
|
Prosesserer…
|
||||||
|
|
@ -294,8 +327,14 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{:else if dragOver}
|
||||||
<p class="text-sm text-purple-600 font-medium">Slipp her for AI-prosessering</p>
|
<p class="text-sm font-medium" style="color: {presetColor};">Slipp her for AI-prosessering</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm text-gray-400">Dra en tekstnode hit</p>
|
<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>
|
<p class="text-xs text-gray-300 mt-1">Støtter innholds- og kommunikasjonsnoder</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Node } from '$lib/spacetime';
|
import type { Node } from '$lib/spacetime';
|
||||||
import { edgeStore, nodeStore } from '$lib/spacetime';
|
import { edgeStore, nodeStore } from '$lib/spacetime';
|
||||||
|
import { setDragPayload } from '$lib/transfer';
|
||||||
import TraitPanel from './TraitPanel.svelte';
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -31,6 +32,15 @@
|
||||||
}
|
}
|
||||||
return nodes;
|
return nodes;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleDragStart(e: DragEvent, node: Node) {
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
setDragPayload(e.dataTransfer, {
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeKind: node.nodeKind,
|
||||||
|
sourcePanel: 'chat'
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TraitPanel name="chat" label="Samtaler" icon="💬">
|
<TraitPanel name="chat" label="Samtaler" icon="💬">
|
||||||
|
|
@ -40,7 +50,11 @@
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
{#each chatNodes as node (node.id)}
|
{#each chatNodes as node (node.id)}
|
||||||
<li>
|
<li
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e) => handleDragStart(e, node)}
|
||||||
|
class="cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href="/chat/{node.id}"
|
href="/chat/{node.id}"
|
||||||
class="block rounded border border-gray-100 px-3 py-2 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
class="block rounded border border-gray-100 px-3 py-2 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Node, Edge } from '$lib/spacetime';
|
import type { Node, Edge } from '$lib/spacetime';
|
||||||
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
|
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
|
||||||
import { deleteEdge } from '$lib/api';
|
import { deleteEdge, aiProcess } from '$lib/api';
|
||||||
import TraitPanel from './TraitPanel.svelte';
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
import PublishDialog from '$lib/components/PublishDialog.svelte';
|
import PublishDialog from '$lib/components/PublishDialog.svelte';
|
||||||
|
import { getDragPayload, setDragPayload, checkToolToNodeCompat } from '$lib/transfer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
collection: Node;
|
collection: Node;
|
||||||
|
|
@ -59,7 +60,6 @@
|
||||||
if (!isPublishingCollection || !userId) return [];
|
if (!isPublishingCollection || !userId) return [];
|
||||||
const inCollection = new Set(contentItems.map(i => i.node.id));
|
const inCollection = new Set(contentItems.map(i => i.node.id));
|
||||||
const nodes: Node[] = [];
|
const nodes: Node[] = [];
|
||||||
// Find content nodes owned by user that aren't in this collection
|
|
||||||
for (const edge of edgeStore.bySource(userId)) {
|
for (const edge of edgeStore.bySource(userId)) {
|
||||||
if (edge.edgeType !== 'owner') continue;
|
if (edge.edgeType !== 'owner') continue;
|
||||||
const node = nodeStore.get(edge.targetId);
|
const node = nodeStore.get(edge.targetId);
|
||||||
|
|
@ -82,6 +82,108 @@
|
||||||
let unpublishError = $state<string | null>(null);
|
let unpublishError = $state<string | null>(null);
|
||||||
let confirmUnpublish = $state<ContentItem | null>(null);
|
let confirmUnpublish = $state<ContentItem | null>(null);
|
||||||
|
|
||||||
|
// --- AI tool_to_node drop state ---
|
||||||
|
let dropTargetNodeId = $state<string | null>(null);
|
||||||
|
let dropIncompat = $state<{ reason: string; suggestion?: string } | null>(null);
|
||||||
|
let aiProcessing = $state<string | null>(null);
|
||||||
|
let aiResult = $state<{ nodeId: string; success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
function parsePresetMetadata(presetId: string): Record<string, unknown> {
|
||||||
|
const node = nodeStore.get(presetId);
|
||||||
|
if (!node) return {};
|
||||||
|
try { return JSON.parse(node.metadata ?? '{}'); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drag-and-drop: items as drag source ---
|
||||||
|
function handleItemDragStart(e: DragEvent, item: ContentItem) {
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
setDragPayload(e.dataTransfer, {
|
||||||
|
nodeId: item.node.id,
|
||||||
|
nodeKind: item.node.nodeKind,
|
||||||
|
sourcePanel: 'editor'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drag-and-drop: accept AI preset drops (tool_to_node) ---
|
||||||
|
function handleItemDragOver(e: DragEvent, item: ContentItem) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
|
||||||
|
const payload = getDragPayload(e.dataTransfer);
|
||||||
|
if (!payload || payload.nodeKind !== 'ai_preset' || !payload.presetId) {
|
||||||
|
// Not an AI preset — ignore (don't accept arbitrary drops on items)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compat = checkToolToNodeCompat(item.node.nodeKind, !!item.node.content?.trim());
|
||||||
|
if (!compat.compatible) {
|
||||||
|
dropIncompat = { reason: compat.reason!, suggestion: compat.suggestion };
|
||||||
|
e.dataTransfer.dropEffect = 'none';
|
||||||
|
} else {
|
||||||
|
dropIncompat = null;
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
dropTargetNodeId = item.node.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemDragLeave(e: DragEvent, item: ContentItem) {
|
||||||
|
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) {
|
||||||
|
if (dropTargetNodeId === item.node.id) {
|
||||||
|
dropTargetNodeId = null;
|
||||||
|
dropIncompat = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleItemDrop(e: DragEvent, item: ContentItem) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropTargetNodeId = null;
|
||||||
|
dropIncompat = null;
|
||||||
|
|
||||||
|
if (!e.dataTransfer || !accessToken) return;
|
||||||
|
const payload = getDragPayload(e.dataTransfer);
|
||||||
|
if (!payload || payload.nodeKind !== 'ai_preset' || !payload.presetId) return;
|
||||||
|
|
||||||
|
const compat = checkToolToNodeCompat(item.node.nodeKind, !!item.node.content?.trim());
|
||||||
|
if (!compat.compatible) {
|
||||||
|
aiResult = {
|
||||||
|
nodeId: item.node.id,
|
||||||
|
success: false,
|
||||||
|
message: [compat.reason, compat.suggestion].filter(Boolean).join(' ')
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger tool_to_node AI processing
|
||||||
|
aiProcessing = item.node.id;
|
||||||
|
aiResult = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await aiProcess(accessToken, {
|
||||||
|
source_node_id: item.node.id,
|
||||||
|
ai_preset_id: payload.presetId,
|
||||||
|
direction: 'tool_to_node'
|
||||||
|
});
|
||||||
|
const presetNode = nodeStore.get(payload.presetId);
|
||||||
|
aiResult = {
|
||||||
|
nodeId: item.node.id,
|
||||||
|
success: true,
|
||||||
|
message: `${presetNode?.title ?? 'AI'}: jobb opprettet (${res.job_id.slice(0, 8)}…). Noden oppdateres i sanntid.`
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
aiResult = {
|
||||||
|
nodeId: item.node.id,
|
||||||
|
success: false,
|
||||||
|
message: `Feil: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
aiProcessing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUnpublish(item: ContentItem) {
|
async function handleUnpublish(item: ContentItem) {
|
||||||
if (!accessToken || unpublishing) return;
|
if (!accessToken || unpublishing) return;
|
||||||
unpublishing = item.node.id;
|
unpublishing = item.node.id;
|
||||||
|
|
@ -123,52 +225,88 @@
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
{#each contentItems as item (item.node.id)}
|
{#each contentItems as item (item.node.id)}
|
||||||
|
{@const isDropTarget = dropTargetNodeId === item.node.id}
|
||||||
|
{@const isIncompat = isDropTarget && dropIncompat}
|
||||||
|
{@const isProcessing = aiProcessing === item.node.id}
|
||||||
|
{@const itemResult = aiResult?.nodeId === item.node.id ? aiResult : null}
|
||||||
<li
|
<li
|
||||||
class="group rounded border border-gray-100 px-3 py-2 cursor-grab active:cursor-grabbing"
|
class="group rounded border px-3 py-2 transition-colors"
|
||||||
draggable="true"
|
class:cursor-grab={!isProcessing}
|
||||||
ondragstart={(e) => {
|
class:active:cursor-grabbing={!isProcessing}
|
||||||
if (e.dataTransfer) {
|
class:border-gray-100={!isDropTarget && !isProcessing}
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
class:border-purple-400={isDropTarget && !isIncompat}
|
||||||
e.dataTransfer.setData('text/plain', item.node.id);
|
class:bg-purple-50={isDropTarget && !isIncompat}
|
||||||
}
|
class:border-red-400={!!isIncompat}
|
||||||
}}
|
class:bg-red-50={!!isIncompat}
|
||||||
|
class:border-amber-300={isProcessing}
|
||||||
|
class:bg-amber-50={isProcessing}
|
||||||
|
draggable={!isProcessing}
|
||||||
|
ondragstart={(e) => handleItemDragStart(e, item)}
|
||||||
|
ondragover={(e) => handleItemDragOver(e, item)}
|
||||||
|
ondragleave={(e) => handleItemDragLeave(e, item)}
|
||||||
|
ondrop={(e) => handleItemDrop(e, item)}
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
{#if isIncompat}
|
||||||
<div class="min-w-0 flex-1">
|
<!-- Incompat feedback overlay -->
|
||||||
<h4 class="text-sm font-medium text-gray-900">{item.node.title || 'Uten tittel'}</h4>
|
<div class="text-center py-1">
|
||||||
{#if item.node.content}
|
<p class="text-xs text-red-600 font-medium">{dropIncompat?.reason}</p>
|
||||||
<p class="mt-0.5 text-xs text-gray-500 line-clamp-2">{item.node.content.slice(0, 140)}</p>
|
{#if dropIncompat?.suggestion}
|
||||||
{/if}
|
<p class="text-xs text-red-400 mt-0.5">{dropIncompat.suggestion}</p>
|
||||||
{#if isPublishingCollection && pubSlug}
|
|
||||||
<p class="mt-1 text-xs text-gray-400">
|
|
||||||
/pub/{pubSlug}/{item.node.id.slice(0, 8)}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isPublishingCollection && accessToken}
|
{:else if isDropTarget}
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
<!-- Compatible drop target -->
|
||||||
{#if pubSlug}
|
<div class="text-center py-1">
|
||||||
<a
|
<p class="text-xs text-purple-600 font-medium">Slipp for in-place revisjon</p>
|
||||||
href="/api/pub/{pubSlug}/{item.node.id.slice(0, 8)}"
|
</div>
|
||||||
target="_blank"
|
{:else}
|
||||||
rel="noopener"
|
<div class="flex items-start justify-between gap-2">
|
||||||
class="rounded px-2 py-1 text-xs text-gray-500 opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:text-gray-700"
|
<div class="min-w-0 flex-1">
|
||||||
title="Se publisert artikkel"
|
<h4 class="text-sm font-medium text-gray-900">
|
||||||
>
|
{item.node.title || 'Uten tittel'}
|
||||||
Se
|
{#if isProcessing}
|
||||||
</a>
|
<span class="ml-1 text-xs text-amber-600">Prosesserer…</span>
|
||||||
|
{/if}
|
||||||
|
</h4>
|
||||||
|
{#if item.node.content}
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500 line-clamp-2">{item.node.content.slice(0, 140)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if isPublishingCollection && pubSlug}
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
/pub/{pubSlug}/{item.node.id.slice(0, 8)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if itemResult}
|
||||||
|
<p class="mt-1 text-xs {itemResult.success ? 'text-green-600' : 'text-red-600'}">
|
||||||
|
{itemResult.message}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
|
||||||
onclick={() => { confirmUnpublish = item; }}
|
|
||||||
class="rounded px-2 py-1 text-xs text-red-500 opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-700"
|
|
||||||
title="Fjern fra publisering"
|
|
||||||
disabled={unpublishing === item.node.id}
|
|
||||||
>
|
|
||||||
{unpublishing === item.node.id ? '...' : 'Avpubliser'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if isPublishingCollection && accessToken}
|
||||||
</div>
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
{#if pubSlug}
|
||||||
|
<a
|
||||||
|
href="/api/pub/{pubSlug}/{item.node.id.slice(0, 8)}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="rounded px-2 py-1 text-xs text-gray-500 opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
title="Se publisert artikkel"
|
||||||
|
>
|
||||||
|
Se
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={() => { confirmUnpublish = item; }}
|
||||||
|
class="rounded px-2 py-1 text-xs text-red-500 opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-700"
|
||||||
|
title="Fjern fra publisering"
|
||||||
|
disabled={unpublishing === item.node.id}
|
||||||
|
>
|
||||||
|
{unpublishing === item.node.id ? '...' : 'Avpubliser'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
120
frontend/src/lib/transfer.ts
Normal file
120
frontend/src/lib/transfer.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* Transfer service — drag-and-drop compatibility between tool panels.
|
||||||
|
*
|
||||||
|
* Centralizes compatibility checking and incompatibility messages
|
||||||
|
* for the universal transfer system (universell overføring).
|
||||||
|
*
|
||||||
|
* Ref: docs/features/universell_overfoering.md
|
||||||
|
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ToolType = 'ai_tool' | 'editor' | 'chat' | 'kanban' | 'calendar' | 'studio';
|
||||||
|
|
||||||
|
export interface DragPayload {
|
||||||
|
nodeId: string;
|
||||||
|
nodeKind: string;
|
||||||
|
/** Optional: AI preset ID when dragging from AI tool panel */
|
||||||
|
presetId?: string;
|
||||||
|
sourcePanel: ToolType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompatResult {
|
||||||
|
compatible: boolean;
|
||||||
|
/** Human-readable reason when incompatible */
|
||||||
|
reason?: string;
|
||||||
|
/** Suggested alternative action */
|
||||||
|
suggestion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_TYPE = 'application/x-synops-transfer';
|
||||||
|
|
||||||
|
/** Encode drag payload into dataTransfer */
|
||||||
|
export function setDragPayload(dt: DataTransfer, payload: DragPayload): void {
|
||||||
|
dt.setData('text/plain', payload.nodeId);
|
||||||
|
dt.setData(MIME_TYPE, JSON.stringify(payload));
|
||||||
|
dt.effectAllowed = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode drag payload from dataTransfer. Falls back to plain text node ID. */
|
||||||
|
export function getDragPayload(dt: DataTransfer): DragPayload | null {
|
||||||
|
const json = dt.getData(MIME_TYPE);
|
||||||
|
if (json) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(json) as DragPayload;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
const nodeId = dt.getData('text/plain');
|
||||||
|
if (nodeId) {
|
||||||
|
return { nodeId, nodeKind: 'unknown', sourcePanel: 'editor' as ToolType };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Media node kinds that are incompatible with text-only tools */
|
||||||
|
const MEDIA_KINDS = new Set(['media', 'audio', 'image', 'video']);
|
||||||
|
|
||||||
|
/** Check if a node kind represents media content */
|
||||||
|
function isMediaKind(nodeKind: string): boolean {
|
||||||
|
return MEDIA_KINDS.has(nodeKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a node kind represents audio */
|
||||||
|
function isAudioKind(nodeKind: string): boolean {
|
||||||
|
return nodeKind === 'audio' || nodeKind === 'media'; // media is often audio in this context
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check compatibility for dropping a node onto the AI tool panel.
|
||||||
|
*/
|
||||||
|
export function checkAiToolCompat(nodeKind: string, hasContent: boolean): CompatResult {
|
||||||
|
if (isMediaKind(nodeKind)) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: 'AI-verktøyet behandler kun tekst.',
|
||||||
|
suggestion: nodeKind === 'audio' || nodeKind === 'media'
|
||||||
|
? 'Transkriber lydfilen først, og dra transkripsjonen hit.'
|
||||||
|
: 'Kun tekstnoder kan prosesseres av AI-verktøyet.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (nodeKind !== 'content' && nodeKind !== 'communication') {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: `Nodetypen «${nodeKind}» støttes ikke av AI-verktøyet.`,
|
||||||
|
suggestion: 'Kun innholds- og kommunikasjonsnoder med tekst kan prosesseres.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!hasContent) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: 'Noden har ikke noe tekstinnhold å prosessere.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { compatible: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check compatibility for dropping an AI preset onto a content node
|
||||||
|
* (tool_to_node direction — in-place revision).
|
||||||
|
*/
|
||||||
|
export function checkToolToNodeCompat(nodeKind: string, hasContent: boolean): CompatResult {
|
||||||
|
if (isMediaKind(nodeKind)) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: 'AI-verktøyet kan ikke revidere mediefiler.',
|
||||||
|
suggestion: 'Kun tekstnoder kan revideres in-place.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (nodeKind !== 'content') {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: `Kun innholdsnoder kan revideres in-place.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!hasContent) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reason: 'Noden har ikke noe tekstinnhold å revidere.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { compatible: true };
|
||||||
|
}
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -205,8 +205,7 @@ Ref: `docs/features/ai_verktoy.md`, `docs/retninger/arbeidsflaten.md`
|
||||||
- [x] 18.2 AI-prosessering endepunkt: `POST /intentions/ai_process` med source_node_id, ai_preset_id, direction (node_to_tool / tool_to_node). Maskinrommet henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias, sender til AI Gateway. Logg forbruk i ai_usage_log.
|
- [x] 18.2 AI-prosessering endepunkt: `POST /intentions/ai_process` med source_node_id, ai_preset_id, direction (node_to_tool / tool_to_node). Maskinrommet henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias, sender til AI Gateway. Logg forbruk i ai_usage_log.
|
||||||
- [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.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.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.
|
||||||
- [~] 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.
|
- [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.
|
||||||
> Påbegynt: 2026-03-18T06:51
|
|
||||||
- [ ] 18.6 Egendefinerte presets: brukere kan opprette egne AI-preset-noder med custom prompt. Dele via edges til samling/team. Modellprofil satt av admin.
|
- [ ] 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
|
## Fase 19: Arbeidsflaten — Spatial Canvas
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue