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:
|
||||
* - Dra en node HIT → node_to_tool (ny node med AI-output)
|
||||
* - Dra verktøyet PÅ en node → tool_to_node (in-place revisjon)
|
||||
* For nå støtter panelet kun node_to_tool (drop-mottak).
|
||||
* - 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 } from '$lib/api';
|
||||
import { getDragPayload, checkAiToolCompat, setDragPayload } from '$lib/transfer';
|
||||
|
||||
interface Props {
|
||||
accessToken?: string;
|
||||
|
|
@ -25,6 +28,7 @@
|
|||
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);
|
||||
|
|
@ -32,7 +36,6 @@
|
|||
// --- Derived: AI-preset noder fra STDB ---
|
||||
const presets = $derived.by(() => {
|
||||
const all = nodeStore.byKind('ai_preset');
|
||||
// Sorter: standard først, deretter custom, alfabetisk innenfor
|
||||
return all.sort((a, b) => {
|
||||
const metaA = parseMetadata(a);
|
||||
const metaB = parseMetadata(b);
|
||||
|
|
@ -49,6 +52,9 @@
|
|||
|
||||
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) {
|
||||
|
|
@ -75,7 +81,6 @@
|
|||
|
||||
function presetIcon(meta: Record<string, unknown>): string {
|
||||
const icon = meta.icon as string;
|
||||
// Map heroicon names to unicode/emoji approximations
|
||||
const iconMap: Record<string, string> = {
|
||||
sparkles: '✨',
|
||||
check_badge: '✅',
|
||||
|
|
@ -89,56 +94,78 @@
|
|||
return iconMap[icon] ?? '🤖';
|
||||
}
|
||||
|
||||
// --- Drag-and-drop ---
|
||||
// --- Drag-and-drop: receiving nodes ---
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
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) {
|
||||
// Only leave if actually leaving the drop zone
|
||||
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;
|
||||
|
||||
const nodeId = e.dataTransfer?.getData('text/plain');
|
||||
if (!nodeId) return;
|
||||
if (!e.dataTransfer) return;
|
||||
const payload = getDragPayload(e.dataTransfer);
|
||||
if (!payload) return;
|
||||
|
||||
const node = nodeStore.get(nodeId);
|
||||
const node = nodeStore.get(payload.nodeId);
|
||||
if (!node) return;
|
||||
|
||||
// Sjekk kompatibilitet: kun tekstnoder
|
||||
if (node.nodeKind !== 'content' && node.nodeKind !== 'communication') {
|
||||
// Check compatibility
|
||||
const compat = checkAiToolCompat(node.nodeKind, !!node.content?.trim());
|
||||
if (!compat.compatible) {
|
||||
lastResult = {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!node.content?.trim()) {
|
||||
lastResult = {
|
||||
success: false,
|
||||
message: 'Noden har ikke noe tekstinnhold å prosessere.'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
droppedNodeId = nodeId;
|
||||
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;
|
||||
|
|
@ -188,7 +215,7 @@
|
|||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Prompt-velger -->
|
||||
<!-- Prompt-velger (presets er draggable for tool_to_node) -->
|
||||
<div>
|
||||
<label for="ai-preset-select" class="block text-xs font-medium text-gray-500 mb-1">
|
||||
Velg verktøy
|
||||
|
|
@ -201,17 +228,21 @@
|
|||
{@const meta = parseMetadata(preset)}
|
||||
<button
|
||||
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
|
||||
? '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 ? `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="truncate">{preset.title}</span>
|
||||
</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>
|
||||
|
||||
|
|
@ -248,19 +279,21 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Drop-sone -->
|
||||
<!-- 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
|
||||
{dragOver
|
||||
? 'border-purple-400 bg-purple-50'
|
||||
: droppedNode
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-gray-200 bg-gray-50'}"
|
||||
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 -->
|
||||
|
|
@ -275,10 +308,10 @@
|
|||
<button
|
||||
onclick={processNode}
|
||||
disabled={!selectedPresetId || processing}
|
||||
class="rounded-lg px-4 py-1.5 text-xs font-medium text-white transition-colors
|
||||
{!selectedPresetId || processing
|
||||
? 'bg-gray-300 cursor-not-allowed'
|
||||
: 'bg-purple-600 hover:bg-purple-700'}"
|
||||
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…
|
||||
|
|
@ -294,8 +327,14 @@
|
|||
</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 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}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { Node } from '$lib/spacetime';
|
||||
import { edgeStore, nodeStore } from '$lib/spacetime';
|
||||
import { setDragPayload } from '$lib/transfer';
|
||||
import TraitPanel from './TraitPanel.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -31,6 +32,15 @@
|
|||
}
|
||||
return nodes;
|
||||
});
|
||||
|
||||
function handleDragStart(e: DragEvent, node: Node) {
|
||||
if (!e.dataTransfer) return;
|
||||
setDragPayload(e.dataTransfer, {
|
||||
nodeId: node.id,
|
||||
nodeKind: node.nodeKind,
|
||||
sourcePanel: 'chat'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<TraitPanel name="chat" label="Samtaler" icon="💬">
|
||||
|
|
@ -40,7 +50,11 @@
|
|||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each chatNodes as node (node.id)}
|
||||
<li>
|
||||
<li
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleDragStart(e, node)}
|
||||
class="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<a
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<script lang="ts">
|
||||
import type { Node, Edge } 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 PublishDialog from '$lib/components/PublishDialog.svelte';
|
||||
import { getDragPayload, setDragPayload, checkToolToNodeCompat } from '$lib/transfer';
|
||||
|
||||
interface Props {
|
||||
collection: Node;
|
||||
|
|
@ -59,7 +60,6 @@
|
|||
if (!isPublishingCollection || !userId) return [];
|
||||
const inCollection = new Set(contentItems.map(i => i.node.id));
|
||||
const nodes: Node[] = [];
|
||||
// Find content nodes owned by user that aren't in this collection
|
||||
for (const edge of edgeStore.bySource(userId)) {
|
||||
if (edge.edgeType !== 'owner') continue;
|
||||
const node = nodeStore.get(edge.targetId);
|
||||
|
|
@ -82,6 +82,108 @@
|
|||
let unpublishError = $state<string | 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) {
|
||||
if (!accessToken || unpublishing) return;
|
||||
unpublishing = item.node.id;
|
||||
|
|
@ -123,19 +225,49 @@
|
|||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#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
|
||||
class="group rounded border border-gray-100 px-3 py-2 cursor-grab active:cursor-grabbing"
|
||||
draggable="true"
|
||||
ondragstart={(e) => {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('text/plain', item.node.id);
|
||||
}
|
||||
}}
|
||||
class="group rounded border px-3 py-2 transition-colors"
|
||||
class:cursor-grab={!isProcessing}
|
||||
class:active:cursor-grabbing={!isProcessing}
|
||||
class:border-gray-100={!isDropTarget && !isProcessing}
|
||||
class:border-purple-400={isDropTarget && !isIncompat}
|
||||
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)}
|
||||
>
|
||||
{#if isIncompat}
|
||||
<!-- Incompat feedback overlay -->
|
||||
<div class="text-center py-1">
|
||||
<p class="text-xs text-red-600 font-medium">{dropIncompat?.reason}</p>
|
||||
{#if dropIncompat?.suggestion}
|
||||
<p class="text-xs text-red-400 mt-0.5">{dropIncompat.suggestion}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isDropTarget}
|
||||
<!-- Compatible drop target -->
|
||||
<div class="text-center py-1">
|
||||
<p class="text-xs text-purple-600 font-medium">Slipp for in-place revisjon</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-sm font-medium text-gray-900">{item.node.title || 'Uten tittel'}</h4>
|
||||
<h4 class="text-sm font-medium text-gray-900">
|
||||
{item.node.title || 'Uten tittel'}
|
||||
{#if isProcessing}
|
||||
<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}
|
||||
|
|
@ -144,6 +276,11 @@
|
|||
/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}
|
||||
</div>
|
||||
{#if isPublishingCollection && accessToken}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
|
|
@ -169,6 +306,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</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.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.
|
||||
- [~] 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
|
||||
- [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.
|
||||
|
||||
## Fase 19: Arbeidsflaten — Spatial Canvas
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue