From b5f47daa63c9f8676b94b17daa183286f58ebdee Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 08:21:35 +0000 Subject: [PATCH] Implementer transfer service med innholdstransfer og lettvekts-triage (oppgave 20.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transfer service bestemmer overføringsmodus basert på verktøy-par: - innholdstransfer (ny node + source_material edge) for transformasjoner (f.eks. chat → editor: chatboble blir kilde for ny artikkel) - lettvekts-triage (ny edge/plassering) for konteksttillegg (f.eks. chat → kanban: noden vises i begge kontekster) Shift-modifier overstyrer alltid til innholdstransfer (ny node). Endringer: - transfer.ts: resolveTransferMode() med verktøy-par-matrise, executeTransfer() som kaller API for node/edge-opprettelse - BlockShell: sender e.shiftKey til onDrop-callback - Workspace handlePanelDrop: kobler sammen modus-resolving og API-kall - Docs: oppdatert universell_overfoering.md med implementasjonsstatus Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/features/universell_overfoering.md | 35 ++- .../components/blockshell/BlockShell.svelte | 3 +- .../src/lib/components/blockshell/types.ts | 4 +- frontend/src/lib/transfer.ts | 243 +++++++++++++++++- frontend/src/routes/workspace/+page.svelte | 38 ++- tasks.md | 3 +- 6 files changed, 308 insertions(+), 18 deletions(-) diff --git a/docs/features/universell_overfoering.md b/docs/features/universell_overfoering.md index 5348f7a..a3c063f 100644 --- a/docs/features/universell_overfoering.md +++ b/docs/features/universell_overfoering.md @@ -226,7 +226,39 @@ Sync-workeren persisterer til PG `message_placements`-tabellen. Ny tabell `message_placement` med reducers for place/remove/move. -## 9. Instruks for Claude Code +## 9. Implementasjonsstatus + +### 9.1 Transfer service (`$lib/transfer.ts`) + +Sentral `transferService` med: +- **`resolveTransferMode(source, target, shiftKey)`** — bestemmer modus fra verktøy-par. + Bruker en matrise (`DEFAULT_TRANSFER_MODE`) som mapper source→target til default-modus. + Shift-modifier overstyrer alltid til `innholdstransfer`. +- **`executeTransfer(accessToken, payload, intent, shiftKey)`** — utfører overføringen: + - Innholdstransfer: `createNode()` → `createEdge(source_material)` → `createEdge(belongs_to)` + - Lettvekts-triage: `createEdge(belongs_to)` med placement-metadata +- **Kompatibilitetssjekker** per verktøy-type (`checkChatCompat`, `checkKanbanCompat`, etc.) +- **`createBlockReceiver(toolType)`** — factory for `BlockReceiver`-implementasjoner + +### 9.2 Shift-modifier + +- `BlockShell.handleDropEvent()` sender `e.shiftKey` til `onDrop`-callback +- Workspace `handlePanelDrop()` sender shiftKey videre til `resolveTransferMode()` +- Shift = alltid ny node (innholdstransfer), uansett verktøy-par + +### 9.3 Default-modus per verktøy-par + +| Source → Target | Default-modus | Begrunnelse | +|----------------|---------------|-------------| +| chat → kanban | lettvekts-triage | Noden vises på brettet | +| chat → editor | innholdstransfer | Ny artikkelnode fra chatinnhold | +| chat → calendar | lettvekts-triage | Noden planlegges | +| kanban → calendar | lettvekts-triage | Oppgave planlegges | +| * → editor | innholdstransfer | Artikkelverktøyet skaper nytt innhold | +| * → ai_tool | innholdstransfer | AI skaper nytt innhold | +| * → chat/kanban/calendar/studio | lettvekts-triage | Noden vises i ny kontekst | + +## 10. Instruks for Claude Code - **Innholdstransfer** (ny node) brukes når innholdet transformeres (chat → artikkel) - **Lettvekts-triage** (ny edge/plassering) brukes når kontekst legges til (chat → kanban) - `source_material`-edge kobler ny node tilbake til kilden ved innholdstransfer @@ -236,3 +268,4 @@ Ny tabell `message_placement` med reducers for place/remove/move. - Drag-and-drop bruker HTML5 API mellom paneler, pointer events intra-canvas - Hold overføringslogikken i en sentral `transferService` - Mottaker-interfacet er obligatorisk for alle verktøy-panel-typer +- Shift-modifier overstyrer alltid til innholdstransfer (ny node) diff --git a/frontend/src/lib/components/blockshell/BlockShell.svelte b/frontend/src/lib/components/blockshell/BlockShell.svelte index f3b2ddb..1ae64e9 100644 --- a/frontend/src/lib/components/blockshell/BlockShell.svelte +++ b/frontend/src/lib/components/blockshell/BlockShell.svelte @@ -271,7 +271,8 @@ return; } } - onDrop?.(payload); + // Pass shiftKey so transfer service can override mode + onDrop?.(payload, e.shiftKey); } } diff --git a/frontend/src/lib/components/blockshell/types.ts b/frontend/src/lib/components/blockshell/types.ts index 9a9534d..320c999 100644 --- a/frontend/src/lib/components/blockshell/types.ts +++ b/frontend/src/lib/components/blockshell/types.ts @@ -76,6 +76,6 @@ export interface BlockShellEvents { onFullscreenChange?: (isFullscreen: boolean) => void; /** Panel minimize state changed (double-click header to toggle) */ onMinimizeChange?: (isMinimized: boolean) => void; - /** Drop received on this panel */ - onDrop?: (payload: DragPayload) => void; + /** Drop received on this panel. shiftKey indicates Shift was held (forces innholdstransfer). */ + onDrop?: (payload: DragPayload, shiftKey: boolean) => void; } diff --git a/frontend/src/lib/transfer.ts b/frontend/src/lib/transfer.ts index 09e0de7..72e38c8 100644 --- a/frontend/src/lib/transfer.ts +++ b/frontend/src/lib/transfer.ts @@ -1,15 +1,27 @@ /** - * Transfer service — drag-and-drop compatibility between tool panels. + * Transfer service — drag-and-drop compatibility and execution between tool panels. * - * Centralizes compatibility checking and incompatibility messages - * for the universal transfer system (universell overføring). + * Centralizes compatibility checking, transfer mode resolution, and + * transfer execution for the universal transfer system (universell overføring). * - * Ref: docs/features/universell_overfoering.md + * Two modes: + * - **Innholdstransfer**: Creates new node + source_material edge (content transformation) + * - **Lettvekts-triage**: Adds edge/placement to existing node (context addition) + * + * Default mode is determined by the source→target tool pair. + * Shift-modifier overrides to always create a new node (innholdstransfer). + * + * Ref: docs/features/universell_overfoering.md § 1, 3 * Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise */ +import { createNode, createEdge, type CreateNodeResponse } from '$lib/api.js'; +import type { PlacementIntent } from '$lib/components/blockshell/types.js'; + export type ToolType = 'ai_tool' | 'editor' | 'chat' | 'kanban' | 'calendar' | 'studio'; +export type TransferMode = 'innholdstransfer' | 'lettvekts-triage'; + export interface DragPayload { nodeId: string; nodeKind: string; @@ -256,3 +268,226 @@ export function createBlockReceiver(toolType: ToolType): { canReceive: (payload: } }; } + +// ============================================================================= +// Transfer mode resolution +// Ref: docs/features/universell_overfoering.md § 1.1 +// ============================================================================= + +/** + * Default transfer mode matrix: source → target → mode. + * + * Innholdstransfer (new node): content is *transformed* for a new context. + * e.g. chat → editor: chatboble becomes source material for an article. + * + * Lettvekts-triage (existing node): content *appears* in an additional context. + * e.g. chat → kanban: same node gets a board placement. + * + * Rule of thumb: if the target tool creates a *new kind* of content from + * the source, it's innholdstransfer. If it just shows the same node in + * a different view, it's lettvekts-triage. + */ +const DEFAULT_TRANSFER_MODE: Partial>>> = { + chat: { + kanban: 'lettvekts-triage', // Same node appears on board + calendar: 'lettvekts-triage', // Same node gets scheduled + editor: 'innholdstransfer', // New article from chat content + studio: 'lettvekts-triage', // Open audio in studio + ai_tool: 'innholdstransfer', // AI creates new content from source + chat: 'lettvekts-triage', // Cross-chat share + }, + kanban: { + chat: 'lettvekts-triage', // Share task in chat + calendar: 'lettvekts-triage', // Schedule existing task + editor: 'innholdstransfer', // Task becomes article source + studio: 'lettvekts-triage', // Open in studio + ai_tool: 'innholdstransfer', // AI processes task + kanban: 'lettvekts-triage', // Cross-board move + }, + editor: { + chat: 'lettvekts-triage', // Share article in chat + kanban: 'lettvekts-triage', // Track article as task + calendar: 'lettvekts-triage', // Schedule article + studio: 'lettvekts-triage', // Open media in studio + ai_tool: 'innholdstransfer', // AI revises article + editor: 'innholdstransfer', // Article → new article (fork) + }, + calendar: { + chat: 'lettvekts-triage', // Share event in chat + kanban: 'lettvekts-triage', // Track event as task + editor: 'innholdstransfer', // Event becomes article source + studio: 'lettvekts-triage', // Open in studio + ai_tool: 'innholdstransfer', // AI processes event + calendar: 'lettvekts-triage', // Cross-calendar + }, + studio: { + chat: 'lettvekts-triage', // Share audio in chat + kanban: 'lettvekts-triage', // Track audio as task + calendar: 'lettvekts-triage', // Schedule audio + editor: 'innholdstransfer', // Audio → article source + ai_tool: 'innholdstransfer', // AI processes audio + studio: 'lettvekts-triage', // Cross-studio + }, + ai_tool: { + chat: 'lettvekts-triage', // Share AI result in chat + kanban: 'lettvekts-triage', // Track result as task + calendar: 'lettvekts-triage', // Schedule result + editor: 'innholdstransfer', // AI result → article source + studio: 'lettvekts-triage', // Open in studio + ai_tool: 'innholdstransfer', // Chain AI processing + }, +}; + +/** + * Resolve the transfer mode for a source→target tool pair. + * + * - Default mode comes from the tool-pair matrix above. + * - Shift-modifier forces innholdstransfer (always create new node). + * + * @param source - Source tool type (where content was dragged from) + * @param target - Target tool type (where content was dropped) + * @param shiftKey - Whether Shift was held during drop (forces new node) + * @returns The resolved transfer mode + */ +export function resolveTransferMode( + source: ToolType, + target: ToolType, + shiftKey: boolean = false, +): TransferMode { + // Shift override: always create a new node + if (shiftKey) return 'innholdstransfer'; + + // Look up default from matrix + const sourceMap = DEFAULT_TRANSFER_MODE[source]; + if (sourceMap) { + const mode = sourceMap[target]; + if (mode) return mode; + } + + // Fallback: if the target creates new content (editor, ai_tool), use innholdstransfer. + // Otherwise default to lettvekts-triage (most common case). + if (target === 'editor' || target === 'ai_tool') return 'innholdstransfer'; + return 'lettvekts-triage'; +} + +// ============================================================================= +// Transfer execution +// Ref: docs/features/universell_overfoering.md § 1, 3, 4 +// ============================================================================= + +/** Result of a completed transfer */ +export interface TransferResult { + mode: TransferMode; + /** The node that ended up in the target (original for triage, new for innholdstransfer) */ + nodeId: string; + /** Edge ID for the source_material edge (only for innholdstransfer) */ + sourceEdgeId?: string; + /** Edge ID for the placement/context edge (for lettvekts-triage) */ + placementEdgeId?: string; +} + +/** + * Execute a transfer: either create a new node with source_material edge + * (innholdstransfer) or add an edge/placement to the existing node + * (lettvekts-triage). + * + * @param accessToken - Auth token for API calls + * @param payload - The drag payload (source node info) + * @param intent - The placement intent from the receiver (target info) + * @param shiftKey - Whether Shift was held (overrides mode to innholdstransfer) + */ +export async function executeTransfer( + accessToken: string, + payload: DragPayload, + intent: PlacementIntent, + shiftKey: boolean = false, +): Promise { + const mode = resolveTransferMode(payload.sourcePanel, intent.contextType as ToolType, shiftKey); + + if (mode === 'innholdstransfer') { + return executeInnholdstransfer(accessToken, payload, intent); + } else { + return executeLettvektsTriage(accessToken, payload, intent); + } +} + +/** + * Innholdstransfer: Create a new node and link it back to the source + * with a source_material edge. + * + * Flow: + * 1. Create new content node (inherits title from source, empty content) + * 2. Create source_material edge: new node → source node + * 3. If target has a context, create belongs_to edge to context + */ +async function executeInnholdstransfer( + accessToken: string, + payload: DragPayload, + intent: PlacementIntent, +): Promise { + // 1. Create a new content node in the target context + const nodeResp: CreateNodeResponse = await createNode(accessToken, { + node_kind: 'content', + visibility: 'hidden', + metadata: { + transfer_source: payload.sourcePanel, + transfer_target: intent.contextType, + }, + }); + + // 2. Create source_material edge: new node → original source + const edgeResp = await createEdge(accessToken, { + source_id: nodeResp.node_id, + target_id: payload.nodeId, + edge_type: 'source_material', + metadata: { + context: 'referenced', + excerpt: '', // Will be populated by UI or AI later + }, + }); + + // 3. Create belongs_to edge to target context if contextId is set + let placementEdgeId: string | undefined; + if (intent.contextId) { + const belongsResp = await createEdge(accessToken, { + source_id: nodeResp.node_id, + target_id: intent.contextId, + edge_type: 'belongs_to', + metadata: intent.position ? { placement: intent.position } : {}, + }); + placementEdgeId = belongsResp.edge_id; + } + + return { + mode: 'innholdstransfer', + nodeId: nodeResp.node_id, + sourceEdgeId: edgeResp.edge_id, + placementEdgeId, + }; +} + +/** + * Lettvekts-triage: Add the existing node to a new context via edge. + * + * Flow: + * 1. Create belongs_to edge from source node to target context + * (with position metadata for kanban column, calendar date, etc.) + */ +async function executeLettvektsTriage( + accessToken: string, + payload: DragPayload, + intent: PlacementIntent, +): Promise { + const edgeResp = await createEdge(accessToken, { + source_id: payload.nodeId, + target_id: intent.contextId, + edge_type: 'belongs_to', + metadata: intent.position ? { placement: intent.position } : {}, + }); + + return { + mode: 'lettvekts-triage', + nodeId: payload.nodeId, + placementEdgeId: edgeResp.edge_id, + }; +} diff --git a/frontend/src/routes/workspace/+page.svelte b/frontend/src/routes/workspace/+page.svelte index a192b0b..14591f3 100644 --- a/frontend/src/routes/workspace/+page.svelte +++ b/frontend/src/routes/workspace/+page.svelte @@ -36,7 +36,7 @@ import MixerTrait from '$lib/components/traits/MixerTrait.svelte'; import GenericTrait from '$lib/components/traits/GenericTrait.svelte'; import AiToolPanel from '$lib/components/AiToolPanel.svelte'; - import { createBlockReceiver, type DragPayload } from '$lib/transfer'; + import { createBlockReceiver, executeTransfer, resolveTransferMode, type DragPayload } from '$lib/transfer'; import type { BlockReceiver } from '$lib/components/blockshell/types'; const session = $derived($page.data.session as Record | undefined); @@ -344,14 +344,36 @@ return receiverCache.get(trait); } - function handlePanelDrop(trait: string, payload: DragPayload) { - // Drop handling will be fully implemented in task 20.4 (transfer service). - // For now, log the intent for debugging. + function handlePanelDrop(trait: string, payload: DragPayload, shiftKey: boolean = false) { const receiver = getReceiverForTrait(trait); - if (receiver?.receive) { - const intent = receiver.receive(payload); - console.log(`[universell-overføring] ${payload.sourcePanel} → ${trait}:`, intent); + if (!receiver?.receive) { + console.warn(`[universell-overføring] Ingen mottaker for ${trait}`); + return; } + + const intent = receiver.receive(payload); + const mode = resolveTransferMode( + payload.sourcePanel, + trait as import('$lib/transfer').ToolType, + shiftKey, + ); + // Override intent mode with centrally resolved mode + intent.mode = mode; + + if (!accessToken) { + console.error('[universell-overføring] Mangler accessToken — kan ikke utføre overføring'); + return; + } + + console.log(`[universell-overføring] ${payload.sourcePanel} → ${trait} (${mode}${shiftKey ? ', shift' : ''}):`, intent); + + executeTransfer(accessToken, payload, intent, shiftKey) + .then((result) => { + console.log(`[universell-overføring] Fullført:`, result); + }) + .catch((err) => { + console.error(`[universell-overføring] Feil:`, err); + }); } @@ -552,7 +574,7 @@ onResize={(w, h) => handlePanelResize(trait, w, h)} onClose={() => handlePanelClose(trait)} onMinimizeChange={(m) => handlePanelMinimize(trait, m)} - onDrop={(payload) => handlePanelDrop(trait, payload)} + onDrop={(payload, shiftKey) => handlePanelDrop(trait, payload, shiftKey)} > {#if knownTraits.has(trait)} {#if trait === 'editor'} diff --git a/tasks.md b/tasks.md index c1e32d7..10204f9 100644 --- a/tasks.md +++ b/tasks.md @@ -226,8 +226,7 @@ Ref: `docs/features/universell_overfoering.md`, `docs/retninger/arbeidsflaten.md - [x] 20.1 message_placements tabell: PG-migrasjon + SpacetimeDB-modul med `place_message`, `remove_placement`, `move_on_canvas` reducers. Synk STDB→PG. Ref: `docs/features/universell_overfoering.md` § 2. - [x] 20.2 source_material edge-type: legg til i edge-skjema + maskinrommet-validering. Støtt kontekst-metadata (quoted, summarized, referenced) og excerpt-felt. Ref: `docs/retninger/arbeidsflaten.md` § "source_material-edge". - [x] 20.3 BlockReceiver interface: implementer `canReceive()`, `receive()`, `renderDropZone()` i alle trait-komponenter (Chat, Kanban, Kalender, Editor, Studio). Kompatibilitetsmatrise bestemmer godkjente drops. Ref: `docs/features/universell_overfoering.md` § 4–5. -- [~] 20.4 Transfer service: `innholdstransfer`-modus (ny node + source_material edge) og `lettvekts-triage` (eksisterende node + ny edge/placement). Bestem modus fra verktøy-par. Shift-modifier for override. Ref: `docs/features/universell_overfoering.md` § 1, 3. - > Påbegynt: 2026-03-18T08:14 +- [x] 20.4 Transfer service: `innholdstransfer`-modus (ny node + source_material edge) og `lettvekts-triage` (eksisterende node + ny edge/placement). Bestem modus fra verktøy-par. Shift-modifier for override. Ref: `docs/features/universell_overfoering.md` § 1, 3. - [ ] 20.5 Panelrework — Chat: gjør ChatTrait til fullverdig BlockShell-panel med BlockReceiver, fullskjerm-toggle, og responsivt design innenfor begrenset container. - [ ] 20.6 Panelrework — Kanban: gjør KanbanTrait til BlockShell-panel med drag-and-drop aksept fra andre paneler, fullskjerm, responsivt. - [ ] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.