Orchestration UI: editor med tre visninger, sanntids kompilering, testkjøring (oppgave 24.6)

Backend:
- POST /intentions/compile_script — kompilerer script, returnerer diagnostikk + kompilert resultat
- POST /intentions/test_orchestration — trigger manuell testkjøring via jobbkø
- GET /query/orchestration_log — henter kjørehistorikk for en orkestrering
- "orchestration" lagt til som gyldig trait for samlingsnoder

Frontend:
- OrchestrationTrait.svelte — BlockShell-panel med:
  - Tre tabber: Enkel (editor), Teknisk (kompilert CLI), Kompilert (JSON)
  - Sanntids kompilering med 500ms debounce og diagnostikk-visning
  - Trigger-velger (6 event-typer) og executor-velger (script/bot/dream)
  - "Test kjøring"-knapp (lagrer + oppretter testjobb i køen)
  - Kjørehistorikk-panel med steg-status, varighet, feilmeldinger
  - Responsiv: container queries + mobile viewport fallback
- Registrert i collection-page (mobil + desktop), workspace/types.ts
- API-funksjoner: compileScript, testOrchestration, fetchOrchestrationLog
This commit is contained in:
vegard 2026-03-18 17:30:52 +00:00
parent 984f5e2f75
commit 376bf7ee62
7 changed files with 1223 additions and 5 deletions

View file

@ -1336,6 +1336,91 @@ export async function toggleMixerEffect(
}); });
} }
// =============================================================================
// Orkestrering (oppgave 24.6)
// =============================================================================
export interface CompileScriptResponse {
diagnostics: Array<{
line: number;
severity: 'Ok' | 'Error';
message: string;
suggestion: string | null;
raw_input: string;
compiled_output: string | null;
}>;
compiled: {
steps: Array<{
step_number: number;
binary: string;
args: string[];
}>;
global_fallback: {
binary: string;
args: string[];
} | null;
technical: string;
} | null;
}
/** Kompiler et orkestreringsscript og få diagnostikk + kompilert resultat. */
export function compileScript(
accessToken: string,
script: string
): Promise<CompileScriptResponse> {
return post(accessToken, '/intentions/compile_script', { script });
}
export interface TestOrchestrationResponse {
job_id: string;
}
/** Trigger en manuell testkjøring av en orkestrering. */
export function testOrchestration(
accessToken: string,
orchestrationId: string
): Promise<TestOrchestrationResponse> {
return post(accessToken, '/intentions/test_orchestration', {
orchestration_id: orchestrationId
});
}
export interface OrchestrationLogEntry {
id: string;
job_id: string | null;
step_number: number;
tool_binary: string;
args: unknown[];
is_fallback: boolean;
status: string;
exit_code: number | null;
error_msg: string | null;
duration_ms: number | null;
created_at: string;
}
export interface OrchestrationLogResponse {
entries: OrchestrationLogEntry[];
}
/** Hent kjørehistorikk for en orkestrering. */
export async function fetchOrchestrationLog(
accessToken: string,
orchestrationId: string,
limit?: number
): Promise<OrchestrationLogResponse> {
const sp = new URLSearchParams({ orchestration_id: orchestrationId });
if (limit) sp.set('limit', String(limit));
const res = await fetch(`${BASE_URL}/query/orchestration_log?${sp}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`orchestration_log failed (${res.status}): ${body}`);
}
return res.json();
}
export async function setMixerRole( export async function setMixerRole(
accessToken: string, accessToken: string,
roomId: string, roomId: string,

View file

@ -0,0 +1,951 @@
<script lang="ts">
import type { Node } from '$lib/realtime';
import { edgeStore, nodeStore, nodeVisibility } from '$lib/realtime';
import {
updateNode,
compileScript,
testOrchestration,
fetchOrchestrationLog,
type CompileScriptResponse,
type OrchestrationLogEntry
} from '$lib/api';
interface Props {
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
}
let { collection, config, userId, accessToken }: Props = $props();
// =========================================================================
// Orchestration nodes in this collection
// =========================================================================
const orchestrationNodes = $derived.by(() => {
const nodes: Node[] = [];
if (!collection) return nodes;
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
if (!node || nodeVisibility(node, userId) === 'hidden') continue;
if (node.nodeKind === 'orchestration') {
nodes.push(node);
}
}
nodes.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
return nodes;
});
// =========================================================================
// State
// =========================================================================
/** Currently selected orchestration node ID */
let selectedId: string | undefined = $state(undefined);
/** Auto-select first orchestration node */
$effect(() => {
if (!selectedId && orchestrationNodes.length > 0) {
selectedId = orchestrationNodes[0].id;
}
});
const selectedNode = $derived(selectedId ? nodeStore.get(selectedId) : undefined);
const selectedMetadata = $derived.by((): Record<string, unknown> => {
if (!selectedNode?.metadata) return {};
try {
return JSON.parse(selectedNode.metadata) as Record<string, unknown>;
} catch {
return {};
}
});
// Editor state
let scriptContent = $state('');
let activeTab: 'enkel' | 'teknisk' | 'kompilert' = $state('enkel');
let compileResult = $state<CompileScriptResponse | null>(null);
let compiling = $state(false);
let saving = $state(false);
let testRunning = $state(false);
let lastTestJobId: string | null = $state(null);
// Trigger state
let triggerEvent = $state('manual');
let triggerConditions = $state('');
let executor = $state('script');
// History
let logEntries: OrchestrationLogEntry[] = $state([]);
let showHistory = $state(false);
// Load content when selected node changes
$effect(() => {
if (selectedNode) {
scriptContent = selectedNode.content ?? '';
const meta = selectedMetadata;
const trigger = meta.trigger as Record<string, unknown> | undefined;
triggerEvent = (trigger?.event as string) ?? 'manual';
const conditions = trigger?.conditions;
triggerConditions = conditions ? JSON.stringify(conditions, null, 2) : '';
executor = (meta.executor as string) ?? 'script';
}
});
// =========================================================================
// Compile (debounced)
// =========================================================================
let compileTimeout: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
// React to script changes
const _content = scriptContent;
clearTimeout(compileTimeout);
if (!accessToken || !scriptContent.trim()) {
compileResult = null;
return;
}
compileTimeout = setTimeout(async () => {
compiling = true;
try {
compileResult = await compileScript(accessToken!, scriptContent);
} catch (err) {
console.error('Compile failed:', err);
} finally {
compiling = false;
}
}, 500);
});
// =========================================================================
// Derived views
// =========================================================================
const technicalView = $derived(compileResult?.compiled?.technical ?? '(ingen kompilert utdata)');
const compiledJson = $derived(
compileResult?.compiled
? JSON.stringify(compileResult.compiled, null, 2)
: '(kompilering feilet eller ingen inndata)'
);
const errorCount = $derived(
compileResult?.diagnostics?.filter((d) => d.severity === 'Error').length ?? 0
);
const hasErrors = $derived(errorCount > 0);
// =========================================================================
// Actions
// =========================================================================
async function save() {
if (!accessToken || !selectedId) return;
saving = true;
try {
let conditionsObj: Record<string, unknown> | undefined;
if (triggerConditions.trim()) {
try {
conditionsObj = JSON.parse(triggerConditions);
} catch {
// Invalid JSON, skip conditions
}
}
const metadata: Record<string, unknown> = {
...selectedMetadata,
trigger: {
event: triggerEvent,
...(conditionsObj ? { conditions: conditionsObj } : {}),
},
executor,
};
// Include compiled pipeline if compilation succeeded
if (compileResult?.compiled) {
metadata.pipeline = compileResult.compiled.steps;
if (compileResult.compiled.global_fallback) {
metadata.global_fallback = compileResult.compiled.global_fallback;
}
}
await updateNode(accessToken, {
node_id: selectedId,
content: scriptContent,
metadata,
});
} catch (err) {
console.error('Save failed:', err);
} finally {
saving = false;
}
}
async function runTest() {
if (!accessToken || !selectedId) return;
// Save first
await save();
testRunning = true;
try {
const result = await testOrchestration(accessToken, selectedId);
lastTestJobId = result.job_id;
} catch (err) {
console.error('Test run failed:', err);
} finally {
testRunning = false;
}
}
async function loadHistory() {
if (!accessToken || !selectedId) return;
try {
const result = await fetchOrchestrationLog(accessToken, selectedId, 30);
logEntries = result.entries;
} catch (err) {
console.error('Failed to load history:', err);
}
}
function toggleHistory() {
showHistory = !showHistory;
if (showHistory) loadHistory();
}
// =========================================================================
// Trigger event options
// =========================================================================
const TRIGGER_EVENTS = [
{ value: 'node.created', label: 'Node opprettet' },
{ value: 'edge.created', label: 'Edge opprettet' },
{ value: 'communication.ended', label: 'Samtale avsluttet' },
{ value: 'node.published', label: 'Node publisert' },
{ value: 'scheduled.due', label: 'Planlagt tidspunkt' },
{ value: 'manual', label: 'Manuell' },
];
// =========================================================================
// Helpers
// =========================================================================
function formatTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString('nb-NO', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
</script>
<div class="orch-trait">
{#if orchestrationNodes.length === 0}
<div class="orch-empty">
<span class="orch-empty-icon">&#9889;</span>
<p>Ingen orkestreringer i denne samlingen.</p>
<p class="orch-empty-hint">
Opprett en orchestration-node for å automatisere arbeidsflyter.
</p>
</div>
{:else}
<!-- Orchestration selector (if multiple) -->
{#if orchestrationNodes.length > 1}
<div class="orch-selector">
<select
bind:value={selectedId}
class="orch-select"
>
{#each orchestrationNodes as node (node.id)}
<option value={node.id}>{node.title ?? 'Uten tittel'}</option>
{/each}
</select>
</div>
{:else}
<div class="orch-selector">
<span class="orch-title">{orchestrationNodes[0]?.title ?? 'Orkestrering'}</span>
</div>
{/if}
<!-- Trigger configuration -->
<div class="orch-trigger">
<div class="orch-trigger-row">
<label class="orch-label" for="trigger-event">Trigger</label>
<select
id="trigger-event"
bind:value={triggerEvent}
class="orch-select orch-select-sm"
>
{#each TRIGGER_EVENTS as evt (evt.value)}
<option value={evt.value}>{evt.label}</option>
{/each}
</select>
<label class="orch-label" for="executor-select">Executor</label>
<select
id="executor-select"
bind:value={executor}
class="orch-select orch-select-sm"
>
<option value="script">Script</option>
<option value="bot">Bot</option>
<option value="dream">Dream</option>
</select>
</div>
</div>
<!-- Tab bar -->
<div class="orch-tabs">
<button
class="orch-tab"
class:orch-tab-active={activeTab === 'enkel'}
onclick={() => { activeTab = 'enkel'; }}
>
Enkel
{#if hasErrors}
<span class="orch-tab-badge orch-tab-badge-error">{errorCount}</span>
{/if}
</button>
<button
class="orch-tab"
class:orch-tab-active={activeTab === 'teknisk'}
onclick={() => { activeTab = 'teknisk'; }}
>
Teknisk
</button>
<button
class="orch-tab"
class:orch-tab-active={activeTab === 'kompilert'}
onclick={() => { activeTab = 'kompilert'; }}
>
Kompilert
</button>
<div class="orch-tab-spacer"></div>
{#if compiling}
<span class="orch-status orch-status-compiling">Kompilerer...</span>
{:else if compileResult && !hasErrors}
<span class="orch-status orch-status-ok">OK</span>
{:else if hasErrors}
<span class="orch-status orch-status-error">{errorCount} feil</span>
{/if}
</div>
<!-- Tab content -->
<div class="orch-editor-area">
{#if activeTab === 'enkel'}
<div class="orch-editor-wrapper">
<textarea
class="orch-editor"
bind:value={scriptContent}
placeholder={"1. transkriber lydfilen (stor modell)\n ved feil: transkriber lydfilen (medium modell)\n2. oppsummer samtalen\n\nved feil: opprett oppgave \"Pipeline feilet\" (bug)"}
spellcheck="false"
></textarea>
<!-- Inline diagnostics -->
{#if compileResult?.diagnostics && compileResult.diagnostics.length > 0}
<div class="orch-diagnostics">
{#each compileResult.diagnostics as diag (diag.line + '-' + diag.severity)}
<div
class="orch-diag"
class:orch-diag-ok={diag.severity === 'Ok'}
class:orch-diag-error={diag.severity === 'Error'}
>
<span class="orch-diag-icon">
{diag.severity === 'Ok' ? '\u2713' : '\u2717'}
</span>
<span class="orch-diag-line">L{diag.line}</span>
{#if diag.severity === 'Ok' && diag.compiled_output}
<span class="orch-diag-text">{diag.compiled_output}</span>
{:else if diag.severity === 'Error'}
<span class="orch-diag-text">{diag.message}</span>
{#if diag.suggestion}
<span class="orch-diag-suggestion">Mente du: "{diag.suggestion}"?</span>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'teknisk'}
<pre class="orch-readonly">{technicalView}</pre>
{:else if activeTab === 'kompilert'}
<pre class="orch-readonly orch-readonly-json">{compiledJson}</pre>
{/if}
</div>
<!-- Actions -->
<div class="orch-actions">
<button
class="orch-btn orch-btn-primary"
onclick={save}
disabled={saving || !accessToken}
>
{saving ? 'Lagrer...' : 'Lagre'}
</button>
<button
class="orch-btn orch-btn-test"
onclick={runTest}
disabled={testRunning || hasErrors || !accessToken || !scriptContent.trim()}
title={hasErrors ? 'Rett feilene først' : 'Kjør orkestreringen manuelt'}
>
{testRunning ? 'Kjører...' : 'Test kjøring'}
</button>
<button
class="orch-btn orch-btn-secondary"
onclick={toggleHistory}
>
{showHistory ? 'Skjul historikk' : 'Historikk'}
</button>
{#if lastTestJobId}
<span class="orch-test-info">Jobb: {lastTestJobId.slice(0, 8)}...</span>
{/if}
</div>
<!-- History panel -->
{#if showHistory}
<div class="orch-history">
<div class="orch-history-header">
<span>Kjørehistorikk</span>
<button class="orch-btn-refresh" onclick={loadHistory}>Oppdater</button>
</div>
{#if logEntries.length === 0}
<p class="orch-history-empty">Ingen kjøringer ennå.</p>
{:else}
<div class="orch-history-list">
{#each logEntries as entry (entry.id)}
<div
class="orch-history-item"
class:orch-history-ok={entry.status === 'ok'}
class:orch-history-error={entry.status === 'error'}
>
<span class="orch-history-status">
{entry.status === 'ok' ? '\u2713' : '\u2717'}
</span>
<span class="orch-history-step">
{#if entry.is_fallback}FB{:else}#{entry.step_number}{/if}
</span>
<span class="orch-history-binary">{entry.tool_binary}</span>
{#if entry.duration_ms != null}
<span class="orch-history-duration">{entry.duration_ms}ms</span>
{/if}
<span class="orch-history-time">{formatTime(entry.created_at)}</span>
{#if entry.error_msg}
<div class="orch-history-error-msg">{entry.error_msg}</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</div>
<style>
/* ================================================================= */
/* Root */
/* ================================================================= */
.orch-trait {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
font-size: 13px;
}
/* ================================================================= */
/* Empty state */
/* ================================================================= */
.orch-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
color: #9ca3af;
text-align: center;
gap: 4px;
}
.orch-empty-icon {
font-size: 28px;
}
.orch-empty-hint {
font-size: 11px;
color: #d1d5db;
margin-top: 4px;
}
/* ================================================================= */
/* Selector */
/* ================================================================= */
.orch-selector {
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.orch-title {
font-weight: 600;
font-size: 14px;
color: #1f2937;
}
.orch-select {
width: 100%;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 13px;
background: white;
color: #1f2937;
}
.orch-select-sm {
width: auto;
min-width: 100px;
}
/* ================================================================= */
/* Trigger config */
/* ================================================================= */
.orch-trigger {
padding: 6px 12px;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.orch-trigger-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.orch-label {
font-size: 11px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
/* ================================================================= */
/* Tabs */
/* ================================================================= */
.orch-tabs {
display: flex;
align-items: center;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
padding: 0 8px;
gap: 0;
}
.orch-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: none;
background: transparent;
font-size: 12px;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.12s, border-color 0.12s;
}
.orch-tab:hover {
color: #374151;
}
.orch-tab-active {
color: #4f46e5;
border-bottom-color: #4f46e5;
font-weight: 500;
}
.orch-tab-badge {
font-size: 10px;
border-radius: 9999px;
padding: 0 5px;
line-height: 16px;
}
.orch-tab-badge-error {
background: #fef2f2;
color: #dc2626;
}
.orch-tab-spacer {
flex: 1;
}
.orch-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 9999px;
white-space: nowrap;
}
.orch-status-compiling {
color: #6b7280;
background: #f3f4f6;
}
.orch-status-ok {
color: #059669;
background: #ecfdf5;
}
.orch-status-error {
color: #dc2626;
background: #fef2f2;
}
/* ================================================================= */
/* Editor area */
/* ================================================================= */
.orch-editor-area {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
.orch-editor-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.orch-editor {
flex: 1;
min-height: 120px;
padding: 10px 12px;
border: none;
resize: none;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.5;
color: #1f2937;
background: #fafbfc;
outline: none;
}
.orch-editor::placeholder {
color: #c4c9d0;
}
.orch-readonly {
flex: 1;
margin: 0;
padding: 10px 12px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
color: #374151;
background: #f9fafb;
border: none;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.orch-readonly-json {
font-size: 11px;
color: #4b5563;
}
/* ================================================================= */
/* Diagnostics */
/* ================================================================= */
.orch-diagnostics {
border-top: 1px solid #e5e7eb;
padding: 6px 8px;
display: flex;
flex-direction: column;
gap: 3px;
max-height: 160px;
overflow-y: auto;
background: white;
flex-shrink: 0;
}
.orch-diag {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 11px;
line-height: 1.4;
flex-wrap: wrap;
}
.orch-diag-icon {
flex-shrink: 0;
font-weight: 700;
}
.orch-diag-ok .orch-diag-icon {
color: #059669;
}
.orch-diag-error .orch-diag-icon {
color: #dc2626;
}
.orch-diag-line {
font-size: 10px;
color: #9ca3af;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.orch-diag-text {
color: #374151;
}
.orch-diag-error .orch-diag-text {
color: #dc2626;
}
.orch-diag-suggestion {
color: #7c3aed;
font-style: italic;
}
/* ================================================================= */
/* Actions */
/* ================================================================= */
.orch-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #e5e7eb;
flex-shrink: 0;
flex-wrap: wrap;
}
.orch-btn {
padding: 5px 12px;
border: 1px solid #d1d5db;
border-radius: 5px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.1s, opacity 0.1s;
}
.orch-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.orch-btn-primary {
background: #4f46e5;
color: white;
border-color: #4f46e5;
}
.orch-btn-primary:hover:not(:disabled) {
background: #4338ca;
}
.orch-btn-test {
background: #059669;
color: white;
border-color: #059669;
}
.orch-btn-test:hover:not(:disabled) {
background: #047857;
}
.orch-btn-secondary {
background: white;
color: #374151;
}
.orch-btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
.orch-test-info {
font-size: 11px;
color: #6b7280;
font-family: monospace;
}
/* ================================================================= */
/* History panel */
/* ================================================================= */
.orch-history {
border-top: 1px solid #e5e7eb;
max-height: 240px;
overflow-y: auto;
background: #fafbfc;
flex-shrink: 0;
}
.orch-history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
color: #374151;
border-bottom: 1px solid #e5e7eb;
position: sticky;
top: 0;
background: #fafbfc;
}
.orch-btn-refresh {
padding: 2px 8px;
border: 1px solid #d1d5db;
border-radius: 3px;
font-size: 10px;
background: white;
cursor: pointer;
color: #4b5563;
}
.orch-btn-refresh:hover {
background: #f3f4f6;
}
.orch-history-empty {
padding: 12px;
text-align: center;
color: #9ca3af;
font-size: 12px;
}
.orch-history-list {
display: flex;
flex-direction: column;
}
.orch-history-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
font-size: 11px;
border-bottom: 1px solid #f3f4f6;
flex-wrap: wrap;
}
.orch-history-status {
flex-shrink: 0;
font-weight: 700;
width: 14px;
text-align: center;
}
.orch-history-ok .orch-history-status {
color: #059669;
}
.orch-history-error .orch-history-status {
color: #dc2626;
}
.orch-history-step {
font-size: 10px;
color: #6b7280;
font-variant-numeric: tabular-nums;
min-width: 20px;
}
.orch-history-binary {
font-family: monospace;
font-size: 11px;
color: #1f2937;
}
.orch-history-duration {
font-size: 10px;
color: #9ca3af;
font-variant-numeric: tabular-nums;
}
.orch-history-time {
font-size: 10px;
color: #9ca3af;
margin-left: auto;
}
.orch-history-error-msg {
width: 100%;
padding: 3px 0 3px 22px;
font-size: 10px;
color: #dc2626;
word-break: break-word;
}
/* ================================================================= */
/* Responsive */
/* ================================================================= */
@container (max-width: 400px) {
.orch-trigger-row {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.orch-select-sm {
width: 100%;
}
.orch-actions {
flex-direction: column;
align-items: stretch;
}
.orch-btn {
width: 100%;
text-align: center;
}
.orch-test-info {
text-align: center;
}
}
@media (max-width: 480px) {
.orch-trigger-row {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.orch-select-sm {
width: 100%;
}
.orch-editor {
min-height: 100px;
font-size: 12px;
}
}
</style>

View file

@ -54,6 +54,7 @@ export const TRAIT_PANEL_INFO: Record<string, TraitPanelInfo> = {
transcription: { title: 'Transkripsjon', icon: '📄', defaultWidth: 500, defaultHeight: 450 }, transcription: { title: 'Transkripsjon', icon: '📄', defaultWidth: 500, defaultHeight: 450 },
studio: { title: 'Studio', icon: '🎛️', defaultWidth: 550, defaultHeight: 450 }, studio: { title: 'Studio', icon: '🎛️', defaultWidth: 550, defaultHeight: 450 },
mixer: { title: 'Mikser', icon: '🎚️', defaultWidth: 450, defaultHeight: 400 }, mixer: { title: 'Mikser', icon: '🎚️', defaultWidth: 450, defaultHeight: 400 },
orchestration: { title: 'Orkestrering', icon: '⚡', defaultWidth: 550, defaultHeight: 500 },
}; };
/** Default info for unknown traits */ /** Default info for unknown traits */

View file

@ -34,6 +34,7 @@
import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte'; import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte';
import StudioTrait from '$lib/components/traits/StudioTrait.svelte'; import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
import MixerTrait from '$lib/components/traits/MixerTrait.svelte'; import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
import OrchestrationTrait from '$lib/components/traits/OrchestrationTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte'; import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte'; import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
import NodeUsage from '$lib/components/NodeUsage.svelte'; import NodeUsage from '$lib/components/NodeUsage.svelte';
@ -70,7 +71,7 @@
/** Traits with dedicated components */ /** Traits with dedicated components */
const knownTraits = new Set([ const knownTraits = new Set([
'editor', 'chat', 'kanban', 'podcast', 'publishing', 'editor', 'chat', 'kanban', 'podcast', 'publishing',
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer' 'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'orchestration'
]); ]);
/** Count of child nodes */ /** Count of child nodes */
@ -354,6 +355,8 @@
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} /> <StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'mixer'} {:else if trait === 'mixer'}
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} /> <MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{:else if trait === 'orchestration'}
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{/if} {/if}
{:else} {:else}
<GenericTrait name={trait} config={traits[trait]} /> <GenericTrait name={trait} config={traits[trait]} />
@ -409,6 +412,8 @@
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} /> <StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'mixer'} {:else if trait === 'mixer'}
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} /> <MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{:else if trait === 'orchestration'}
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{/if} {/if}
{:else} {:else}
<GenericTrait name={trait} config={traits[trait]} /> <GenericTrait name={trait} config={traits[trait]} />

View file

@ -44,7 +44,7 @@ const VALID_TRAITS: &[&str] = &[
// Kunnskap // Kunnskap
"knowledge_graph", "wiki", "glossary", "faq", "bibliography", "knowledge_graph", "wiki", "glossary", "faq", "bibliography",
// Automatisering & AI // Automatisering & AI
"auto_tag", "auto_summarize", "digest", "bridge", "moderation", "ai_tool", "auto_tag", "auto_summarize", "digest", "bridge", "moderation", "ai_tool", "orchestration",
// Tilgang & fellesskap // Tilgang & fellesskap
"membership", "roles", "invites", "paywall", "directory", "membership", "roles", "invites", "paywall", "directory",
// Ekstern integrasjon // Ekstern integrasjon
@ -4326,6 +4326,179 @@ pub struct UpdatePriorityRuleRequest {
pub block_during_livekit: bool, pub block_during_livekit: bool,
} }
// =============================================================================
// Orkestrering: kompilering og testkjøring (oppgave 24.6)
// =============================================================================
#[derive(Deserialize)]
pub struct CompileScriptRequest {
pub script: String,
}
/// Kompiler et orkestreringsscript og returner diagnostikk + kompilert resultat.
/// Brukes av frontend-editoren for sanntids kompileringsfeedback.
pub async fn compile_script(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<CompileScriptRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
use crate::script_compiler;
let result = script_compiler::compile_script(&state.db, &req.script)
.await
.map_err(|e| bad_request(&e))?;
// Serialiser CompileResult til JSON
let json = serde_json::to_value(&result)
.map_err(|e| internal_error(&format!("Serialiseringsfeil: {e}")))?;
Ok(Json(json))
}
#[derive(Deserialize)]
pub struct TestOrchestrationRequest {
pub orchestration_id: Uuid,
}
#[derive(Serialize)]
pub struct TestOrchestrationResponse {
pub job_id: String,
}
/// Kjør en orkestrering manuelt (testkjøring).
/// Oppretter en "orchestrate"-jobb med trigger_event = "manual".
pub async fn test_orchestration(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<TestOrchestrationRequest>,
) -> Result<Json<TestOrchestrationResponse>, (StatusCode, Json<ErrorResponse>)> {
// Verifiser at noden er en orchestration-node
let node = sqlx::query_as::<_, (String, Option<String>)>(
"SELECT node_kind, content FROM nodes WHERE id = $1",
)
.bind(req.orchestration_id)
.fetch_optional(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?
.ok_or_else(|| bad_request("Orkestreringsnode ikke funnet"))?;
if node.0 != "orchestration" {
return Err(bad_request("Noden er ikke en orchestration-node"));
}
// Opprett en jobb i køen
let job_id = sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO job_queue (job_type, collection_node_id, payload, status, priority)
VALUES ('orchestrate', NULL, $1, 'pending', 5)
RETURNING id
"#,
)
.bind(serde_json::json!({
"orchestration_id": req.orchestration_id.to_string(),
"trigger_event": "manual",
"trigger_context": {},
"test_run": true,
"initiated_by": user.node_id.to_string(),
}))
.fetch_one(&state.db)
.await
.map_err(|e| internal_error(&format!("Kunne ikke opprette testjobb: {e}")))?;
tracing::info!(
orchestration_id = %req.orchestration_id,
job_id = %job_id,
user = %user.node_id,
"Manuell testkjøring av orkestrering startet"
);
Ok(Json(TestOrchestrationResponse {
job_id: job_id.to_string(),
}))
}
#[derive(Deserialize)]
pub struct OrchestrationLogParams {
pub orchestration_id: Uuid,
pub limit: Option<i64>,
}
#[derive(Serialize)]
pub struct OrchestrationLogEntry {
pub id: String,
pub job_id: Option<String>,
pub step_number: i16,
pub tool_binary: String,
pub args: serde_json::Value,
pub is_fallback: bool,
pub status: String,
pub exit_code: Option<i16>,
pub error_msg: Option<String>,
pub duration_ms: Option<i32>,
pub created_at: String,
}
#[derive(Serialize)]
pub struct OrchestrationLogResponse {
pub entries: Vec<OrchestrationLogEntry>,
}
/// Hent kjørehistorikk for en orkestrering.
pub async fn orchestration_log(
State(state): State<AppState>,
_user: AuthUser,
axum::extract::Query(params): axum::extract::Query<OrchestrationLogParams>,
) -> Result<Json<OrchestrationLogResponse>, (StatusCode, Json<ErrorResponse>)> {
let limit = params.limit.unwrap_or(50).min(200);
let rows = sqlx::query_as::<_, (
Uuid,
Option<Uuid>,
i16,
String,
serde_json::Value,
bool,
String,
Option<i16>,
Option<String>,
Option<i32>,
chrono::DateTime<chrono::Utc>,
)>(
r#"
SELECT id, job_id, step_number, tool_binary, args, is_fallback,
status, exit_code, error_msg, duration_ms, created_at
FROM orchestration_log
WHERE orchestration_id = $1
ORDER BY created_at DESC
LIMIT $2
"#,
)
.bind(params.orchestration_id)
.bind(limit)
.fetch_all(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
let entries = rows
.into_iter()
.map(|r| OrchestrationLogEntry {
id: r.0.to_string(),
job_id: r.1.map(|u| u.to_string()),
step_number: r.2,
tool_binary: r.3,
args: r.4,
is_fallback: r.5,
status: r.6,
exit_code: r.7,
error_msg: r.8,
duration_ms: r.9,
created_at: r.10.to_rfc3339(),
})
.collect();
Ok(Json(OrchestrationLogResponse { entries }))
}
// ============================================================================= // =============================================================================
// Tester // Tester
// ============================================================================= // =============================================================================
@ -4464,7 +4637,7 @@ mod tests {
"chat", "forum", "comments", "guest_input", "announcements", "polls", "qa", "chat", "forum", "comments", "guest_input", "announcements", "polls", "qa",
"kanban", "calendar", "timeline", "table", "gallery", "bookmarks", "tags", "kanban", "calendar", "timeline", "table", "gallery", "bookmarks", "tags",
"knowledge_graph", "wiki", "glossary", "faq", "bibliography", "knowledge_graph", "wiki", "glossary", "faq", "bibliography",
"auto_tag", "auto_summarize", "digest", "bridge", "moderation", "auto_tag", "auto_summarize", "digest", "bridge", "moderation", "orchestration",
"membership", "roles", "invites", "paywall", "directory", "membership", "roles", "invites", "paywall", "directory",
"webhook", "import", "export", "ical_sync", "webhook", "import", "export", "ical_sync",
]; ];

View file

@ -258,6 +258,10 @@ async fn main() {
.route("/custom-domain/sok", get(custom_domain::serve_custom_domain_search)) .route("/custom-domain/sok", get(custom_domain::serve_custom_domain_search))
.route("/custom-domain/om", get(custom_domain::serve_custom_domain_about)) .route("/custom-domain/om", get(custom_domain::serve_custom_domain_about))
.route("/custom-domain/{article_id}", get(custom_domain::serve_custom_domain_article)) .route("/custom-domain/{article_id}", get(custom_domain::serve_custom_domain_article))
// Orkestrering UI (oppgave 24.6)
.route("/intentions/compile_script", post(intentions::compile_script))
.route("/intentions/test_orchestration", post(intentions::test_orchestration))
.route("/query/orchestration_log", get(intentions::orchestration_log))
// Mixer-kanaler // Mixer-kanaler
.route("/intentions/create_mixer_channel", post(mixer::create_mixer_channel)) .route("/intentions/create_mixer_channel", post(mixer::create_mixer_channel))
.route("/intentions/set_gain", post(mixer::set_gain)) .route("/intentions/set_gain", post(mixer::set_gain))

View file

@ -321,8 +321,7 @@ automatisk eskalering av intelligens ved feil, kompilering av velprøvde mønstr
- [x] 24.3 Script-kompilator: parser menneskelig scriptspråk ("transkriber lydfilen (stor modell)") og kompilerer til tekniske CLI-kall. Matcher verb mot `cli_tool`-noders `aliases`, argumenter mot `args_hints`, variabler fra trigger-kontekst. Rust-stil kompileringsfeil med forslag. - [x] 24.3 Script-kompilator: parser menneskelig scriptspråk ("transkriber lydfilen (stor modell)") og kompilerer til tekniske CLI-kall. Matcher verb mot `cli_tool`-noders `aliases`, argumenter mot `args_hints`, variabler fra trigger-kontekst. Rust-stil kompileringsfeil med forslag.
- [x] 24.4 cli_tool alias-metadata: utvid alle `cli_tool`-noder med `aliases` (norske verb) og `args_hints` (menneskelige argumenter → CLI-flagg). Seed for alle eksisterende verktøy. - [x] 24.4 cli_tool alias-metadata: utvid alle `cli_tool`-noder med `aliases` (norske verb) og `args_hints` (menneskelige argumenter → CLI-flagg). Seed for alle eksisterende verktøy.
- [x] 24.5 Script-executor: vaktmesteren parser kompilert script og eksekverer steg sekvensielt via generisk dispatch. VED_FEIL-håndtering. Logger i `orchestration_log`. - [x] 24.5 Script-executor: vaktmesteren parser kompilert script og eksekverer steg sekvensielt via generisk dispatch. VED_FEIL-håndtering. Logger i `orchestration_log`.
- [~] 24.6 Orchestration UI: editor med tre visninger (Enkel/Teknisk/Kompilert) som tabber. Sanntids kompileringsfeil. Trigger-velger, "Test kjøring"-knapp, kjørehistorikk. Ref: `docs/concepts/orkestrering.md`. - [x] 24.6 Orchestration UI: editor med tre visninger (Enkel/Teknisk/Kompilert) som tabber. Sanntids kompileringsfeil. Trigger-velger, "Test kjøring"-knapp, kjørehistorikk. Ref: `docs/concepts/orkestrering.md`.
> Påbegynt: 2026-03-18T17:20
- [ ] 24.7 AI-assistert oppretting: `synops-ai` med auto-generert systemprompt (fra cli_tool-noder) foreslår script fra fritekst-beskrivelse. Vaktmesteren validerer. Eventually-modus: lagre som work_item for Claude Code. - [ ] 24.7 AI-assistert oppretting: `synops-ai` med auto-generert systemprompt (fra cli_tool-noder) foreslår script fra fritekst-beskrivelse. Vaktmesteren validerer. Eventually-modus: lagre som work_item for Claude Code.
- [ ] 24.8 Kaskade: `triggers`-edge mellom orkestreringer. Output fra én trigger neste. Syklusdeteksjon for å unngå uendelige loops. - [ ] 24.8 Kaskade: `triggers`-edge mellom orkestreringer. Output fra én trigger neste. Syklusdeteksjon for å unngå uendelige loops.
- [ ] 24.9 Seed-orkestreringer: opprett standard-orkestreringer for podcast-pipeline, publiseringsflyt, og AI-beriking basert på eksisterende hardkodet logikk i vaktmesteren. Skrives i menneskelig scriptspråk. - [ ] 24.9 Seed-orkestreringer: opprett standard-orkestreringer for podcast-pipeline, publiseringsflyt, og AI-beriking basert på eksisterende hardkodet logikk i vaktmesteren. Skrives i menneskelig scriptspråk.