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:
parent
984f5e2f75
commit
376bf7ee62
7 changed files with 1223 additions and 5 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
951
frontend/src/lib/components/traits/OrchestrationTrait.svelte
Normal file
951
frontend/src/lib/components/traits/OrchestrationTrait.svelte
Normal 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">⚡</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>
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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]} />
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue