synops/frontend/src/lib/components/traits/OrchestrationTrait.svelte
vegard d18dfc260f AI-assistert oppretting: synops-ai genererer orkestreringsscript fra fritekst (oppgave 24.7)
Nytt CLI-verktøy `synops-ai` som leser cli_tool-noder fra PG, bygger
en systemprompt med tilgjengelige verktøy og script-grammatikk, og
bruker LLM til å foreslå orkestreringsscript fra fritekst-beskrivelse.

Tre moduser:
- Synkron: --description "..." → LLM genererer script → JSON output
- System prompt: --generate-system-prompt → skriver auto-generert prompt
- Eventually: --eventually → lagrer som work_item for Claude Code

Maskinrommet: nytt endepunkt POST /intentions/ai_suggest_script som
kaller synops-ai, validerer resultatet med script_compiler, og returnerer
script + kompileringsresultat til frontend.

Frontend: AI-assistent-knapp i OrchestrationTrait med fritekst-input,
generer-knapp, og feilvisning. Generert script settes direkte i editoren.

Migration: synops-ai seeded som cli_tool-node med norske verb-alias.
2026-03-18 17:47:32 +00:00

1127 lines
26 KiB
Svelte

<script lang="ts">
import type { Node } from '$lib/realtime';
import { edgeStore, nodeStore, nodeVisibility } from '$lib/realtime';
import {
updateNode,
compileScript,
testOrchestration,
fetchOrchestrationLog,
aiSuggestScript,
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');
// AI-assist
let showAiAssist = $state(false);
let aiDescription = $state('');
let aiGenerating = $state(false);
let aiError: string | null = $state(null);
// 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();
}
async function generateWithAi() {
if (!accessToken || !aiDescription.trim()) return;
aiGenerating = true;
aiError = null;
try {
const result = await aiSuggestScript(accessToken, {
description: aiDescription,
trigger_event: triggerEvent !== 'manual' ? triggerEvent : undefined,
collection_id: collection?.id,
});
if (result.script) {
scriptContent = result.script;
aiDescription = '';
showAiAssist = false;
}
if (result.status === 'generated_with_errors' && result.compile_result) {
const errors = result.compile_result.diagnostics
?.filter((d) => d.severity === 'Error')
.map((d) => d.message)
.join('; ');
if (errors) {
aiError = `Script generert med feil: ${errors}`;
}
} else if (result.status === 'deferred') {
aiError = null;
showAiAssist = false;
}
} catch (err) {
aiError = err instanceof Error ? err.message : 'Ukjent feil';
} finally {
aiGenerating = false;
}
}
// =========================================================================
// 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>
<!-- AI-assist panel -->
{#if showAiAssist}
<div class="orch-ai-panel">
<div class="orch-ai-header">
<span class="orch-ai-label">AI-assistent</span>
<button class="orch-btn-close" onclick={() => { showAiAssist = false; }}>&times;</button>
</div>
<textarea
class="orch-ai-input"
bind:value={aiDescription}
placeholder="Beskriv hva orkestreringen skal gjøre..."
rows="3"
disabled={aiGenerating}
></textarea>
{#if aiError}
<div class="orch-ai-error">{aiError}</div>
{/if}
<div class="orch-ai-actions">
<button
class="orch-btn orch-btn-ai"
onclick={generateWithAi}
disabled={aiGenerating || !aiDescription.trim()}
>
{aiGenerating ? 'Genererer...' : 'Generer script'}
</button>
</div>
</div>
{/if}
<!-- 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-ai-toggle"
onclick={() => { showAiAssist = !showAiAssist; }}
title="Generer script fra fritekst-beskrivelse med AI"
>
{showAiAssist ? 'Skjul AI' : 'AI-assistent'}
</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;
}
/* ================================================================= */
/* AI-assist panel */
/* ================================================================= */
.orch-ai-panel {
border-top: 1px solid #e5e7eb;
padding: 8px 12px;
background: #f0f4ff;
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.orch-ai-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.orch-ai-label {
font-size: 11px;
font-weight: 600;
color: #4f46e5;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.orch-btn-close {
border: none;
background: transparent;
font-size: 16px;
color: #6b7280;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.orch-btn-close:hover {
color: #1f2937;
}
.orch-ai-input {
width: 100%;
padding: 6px 8px;
border: 1px solid #c7d2fe;
border-radius: 4px;
font-size: 13px;
font-family: inherit;
resize: vertical;
min-height: 48px;
background: white;
color: #1f2937;
}
.orch-ai-input::placeholder {
color: #a5b4fc;
}
.orch-ai-input:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
.orch-ai-error {
font-size: 11px;
color: #dc2626;
background: #fef2f2;
padding: 4px 8px;
border-radius: 3px;
}
.orch-ai-actions {
display: flex;
gap: 6px;
}
.orch-btn-ai {
background: #4f46e5;
color: white;
border-color: #4f46e5;
}
.orch-btn-ai:hover:not(:disabled) {
background: #4338ca;
}
.orch-btn-ai-toggle {
background: #eef2ff;
color: #4f46e5;
border-color: #c7d2fe;
}
.orch-btn-ai-toggle:hover:not(:disabled) {
background: #e0e7ff;
}
/* ================================================================= */
/* 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>