gigafibre-fsm/apps/ops/src/pages/AgentFlowsPage.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

488 lines
24 KiB
Vue

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { usePermissions } from 'src/composables/usePermissions'
import FlowCanvas from 'src/components/flow-editor/FlowCanvas.vue'
const { HUB_URL } = usePermissions()
const flows = ref(null)
const loading = ref(true)
const saving = ref(false)
const dirty = ref(false)
const selectedIntent = ref(null)
const editingStep = ref(null)
const showPrompt = ref(false)
const generatedPrompt = ref('')
const showJson = ref(false)
const showCanvas = ref(false) // POC : vue canvas node-based (Vue Flow) au lieu de l'arbre
async function loadFlows () {
loading.value = true
try {
const res = await fetch(`${HUB_URL}/agent/flows`)
flows.value = await res.json()
if (flows.value.intents?.length && !selectedIntent.value) {
selectedIntent.value = flows.value.intents[0].id
}
} catch (e) { console.error('Load flows error:', e) }
finally { loading.value = false }
}
async function saveFlows () {
saving.value = true
try {
const res = await fetch(`${HUB_URL}/agent/flows`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flows.value),
})
const data = await res.json()
if (data.ok) { dirty.value = false; flows.value.version = data.version }
} catch (e) { console.error('Save error:', e) }
finally { saving.value = false }
}
async function viewPrompt () {
const res = await fetch(`${HUB_URL}/agent/prompt`)
const data = await res.json()
generatedPrompt.value = data.prompt || ''
showPrompt.value = true
}
const currentIntent = computed(() => flows.value?.intents?.find(i => i.id === selectedIntent.value))
// Step tree helpers
function stepsByParent (steps, parentId = null, branch = null) {
return steps.filter(s => {
if (parentId === null) return !s.parent
return s.parent === parentId && (branch === null || s.branch === branch)
})
}
function branchesOf (step) {
if (!currentIntent.value) return []
const steps = currentIntent.value.steps
const children = steps.filter(s => s.parent === step.id)
return [...new Set(children.map(c => c.branch))].filter(Boolean)
}
// Edit step
function openStepEditor (step) {
editingStep.value = JSON.parse(JSON.stringify(step))
}
function saveStep () {
if (!editingStep.value || !currentIntent.value) return
const idx = currentIntent.value.steps.findIndex(s => s.id === editingStep.value.id)
if (idx >= 0) currentIntent.value.steps[idx] = editingStep.value
editingStep.value = null
dirty.value = true
}
function deleteStep (stepId) {
if (!currentIntent.value) return
currentIntent.value.steps = currentIntent.value.steps.filter(s => s.id !== stepId && s.parent !== stepId)
dirty.value = true
}
// Add step
function addStep (parentId = null, branch = null) {
if (!currentIntent.value) return
const id = 'step_' + Date.now().toString(36)
const step = { id, type: 'respond', label: 'Nouvelle étape', message: '' }
if (parentId) { step.parent = parentId; step.branch = branch || 'yes' }
currentIntent.value.steps.push(step)
dirty.value = true
openStepEditor(step)
}
// ── Canvas Vue Flow → édite le MÊME modèle de steps (clic=éditer, lien=parent, glisser=position persistée _pos) ──
function canvasEdit (id) { const s = currentIntent.value?.steps.find(x => x.id === id); if (s) openStepEditor(s) }
function canvasConnect ({ source, target }) {
if (!currentIntent.value || source === target) return
const s = currentIntent.value.steps.find(x => x.id === target)
const p = currentIntent.value.steps.find(x => x.id === source)
if (!s || !p) return
s.parent = source
if ((p.type === 'condition' || p.type === 'switch') && !s.branch) s.branch = 'yes'
dirty.value = true
}
function canvasMove ({ id, position }) { const s = currentIntent.value?.steps.find(x => x.id === id); if (s) { s._pos = { x: position.x, y: position.y }; dirty.value = true } }
// Add intent
function addIntent () {
if (!flows.value) return
const id = 'intent_' + Date.now().toString(36)
flows.value.intents.push({ id, label: 'Nouveau flow', trigger: '', color: '#64748b', steps: [] })
selectedIntent.value = id
dirty.value = true
}
function deleteIntent (id) {
if (!flows.value) return
flows.value.intents = flows.value.intents.filter(i => i.id !== id)
if (selectedIntent.value === id) selectedIntent.value = flows.value.intents[0]?.id || null
dirty.value = true
}
// Persona edit
const editPersona = ref(false)
const stepTypeLabels = {
tool: 'Appel outil', condition: 'Condition', switch: 'Switch', respond: 'Réponse',
action: 'Action', goto: 'Aller à',
}
const severityColors = { info: '#3b82f6', warning: '#f59e0b', critical: '#ef4444' }
onMounted(loadFlows)
</script>
<template>
<q-page class="af-page">
<div v-if="loading" class="af-loading">Chargement des flows...</div>
<template v-else-if="flows">
<!-- Header -->
<div class="af-header">
<div class="af-title">
<span class="af-icon">&#x26A1;</span>
<div>
<h1>Agent AI — Flows</h1>
<span class="af-subtitle">v{{ flows.version || 1 }} · {{ flows.intents?.length || 0 }} flows · {{ flows.updated_at ? new Date(flows.updated_at).toLocaleDateString('fr-CA') : '' }}</span>
</div>
</div>
<div class="af-actions">
<button class="af-btn af-btn-ghost" @click="viewPrompt">Voir prompt</button>
<button class="af-btn af-btn-ghost" @click="showJson = !showJson">{{ showJson ? 'Visuel' : 'JSON' }}</button>
<button class="af-btn af-btn-ghost" @click="showCanvas = !showCanvas">{{ showCanvas ? 'Arbre' : '🎨 Canvas' }}</button>
<button class="af-btn af-btn-primary" :disabled="!dirty || saving" @click="saveFlows">
{{ saving ? 'Sauvegarde...' : dirty ? 'Sauvegarder' : 'Sauvegardé' }}
</button>
</div>
</div>
<!-- Persona card -->
<div class="af-persona" @click="editPersona = !editPersona">
<div class="af-persona-hdr">
<strong>{{ flows.persona?.name || 'Agent' }}</strong> — {{ flows.persona?.role || '' }}
</div>
<div v-if="editPersona" class="af-persona-edit" @click.stop>
<label>Nom</label>
<input v-model="flows.persona.name" @input="dirty = true" />
<label>Rôle</label>
<input v-model="flows.persona.role" @input="dirty = true" />
<label>Ton</label>
<input v-model="flows.persona.tone" @input="dirty = true" />
<label>Règles</label>
<div v-for="(r, i) in flows.persona.rules" :key="i" class="af-rule-row">
<input v-model="flows.persona.rules[i]" @input="dirty = true" />
<button class="af-btn-x" @click="flows.persona.rules.splice(i, 1); dirty = true">x</button>
</div>
<button class="af-btn af-btn-sm" @click="flows.persona.rules.push(''); dirty = true">+ Règle</button>
</div>
<div v-else class="af-persona-rules">
<span v-for="r in flows.persona?.rules" :key="r" class="af-rule-chip">{{ r }}</span>
</div>
</div>
<!-- JSON view -->
<div v-if="showJson" class="af-json-wrap">
<textarea class="af-json" :value="JSON.stringify(flows, null, 2)"
@input="e => { try { flows = JSON.parse(e.target.value); dirty = true } catch {} }" />
</div>
<!-- Visual view -->
<template v-else>
<!-- Intent tabs -->
<div class="af-tabs">
<button v-for="intent in flows.intents" :key="intent.id"
class="af-tab" :class="{ active: selectedIntent === intent.id }"
:style="selectedIntent === intent.id ? `border-color:${intent.color};color:${intent.color}` : ''"
@click="selectedIntent = intent.id">
<span class="af-tab-dot" :style="`background:${intent.color}`"></span>
{{ intent.label }}
</button>
<button class="af-tab af-tab-add" @click="addIntent">+</button>
</div>
<!-- Intent detail -->
<div v-if="currentIntent" class="af-intent">
<div class="af-intent-header">
<input class="af-intent-label" v-model="currentIntent.label" @input="dirty = true" />
<input class="af-intent-color" type="color" v-model="currentIntent.color" @input="dirty = true" />
<button class="af-btn-x af-del-intent" @click="deleteIntent(currentIntent.id)" title="Supprimer ce flow">&#x1F5D1;</button>
</div>
<div class="af-trigger">
<label>Déclencheur:</label>
<input v-model="currentIntent.trigger" @input="dirty = true" placeholder="Quand ce flow se déclenche..." />
</div>
<!-- Canvas node-based (Vue Flow) — POC visuel n8n-like -->
<FlowCanvas v-if="showCanvas" :steps="currentIntent.steps"
@add="addStep()" @edit="canvasEdit" @delete="deleteStep" @connect="canvasConnect" @move="canvasMove" />
<!-- Flow tree (vue arbre) -->
<div v-else class="af-tree">
<div class="af-tree-col">
<template v-for="step in stepsByParent(currentIntent.steps, null)" :key="step.id">
<FlowNode :step="step" :intent="currentIntent" :stepsByParent="stepsByParent" :branchesOf="branchesOf"
@edit="openStepEditor" @delete="deleteStep" @add="addStep" :depth="0" />
</template>
<button class="af-add-root" @click="addStep()">+ Ajouter une étape</button>
</div>
</div>
</div>
</template>
<!-- Step editor modal -->
<div v-if="editingStep" class="af-overlay" @click.self="editingStep = null">
<div class="af-modal">
<div class="af-modal-hdr">
<span>Modifier: {{ editingStep.label || editingStep.id }}</span>
<button @click="editingStep = null">&#x2715;</button>
</div>
<div class="af-modal-body">
<label>ID</label>
<input v-model="editingStep.id" disabled class="af-input-disabled" />
<label>Type</label>
<select v-model="editingStep.type">
<option v-for="(lbl, t) in stepTypeLabels" :key="t" :value="t">{{ lbl }}</option>
</select>
<label>Label</label>
<input v-model="editingStep.label" />
<template v-if="editingStep.type === 'tool'">
<label>Outil</label>
<input v-model="editingStep.tool" placeholder="get_equipment" />
<label>Note</label>
<input v-model="editingStep.note" />
</template>
<template v-if="editingStep.type === 'condition'">
<label>Champ</label>
<input v-model="editingStep.field" placeholder="device.online" />
<div class="af-row">
<div><label>Opérateur</label><select v-model="editingStep.op">
<option value="==">==</option><option value="!=">!=</option>
<option value="<">&lt;</option><option value=">">&gt;</option>
<option value="<=">&lt;=</option><option value=">=">&gt;=</option>
</select></div>
<div><label>Valeur</label><input v-model="editingStep.value" /></div>
</div>
</template>
<template v-if="editingStep.type === 'switch'">
<label>Champ switch</label>
<input v-model="editingStep.field" placeholder="onu.alarm_type" />
</template>
<template v-if="editingStep.type === 'respond'">
<label>Message</label>
<textarea v-model="editingStep.message" rows="4"></textarea>
<label>Note interne</label>
<input v-model="editingStep.note" />
</template>
<template v-if="editingStep.type === 'action'">
<label>Action</label>
<input v-model="editingStep.action" placeholder="create_dispatch_job" />
<label>Paramètres (JSON)</label>
<textarea :value="JSON.stringify(editingStep.params || {}, null, 2)"
@input="e => { try { editingStep.params = JSON.parse(e.target.value) } catch {} }" rows="3"></textarea>
<label>Message au client</label>
<textarea v-model="editingStep.message" rows="2"></textarea>
</template>
<template v-if="editingStep.type === 'goto'">
<label>Cible (intent ID)</label>
<input v-model="editingStep.target" />
</template>
<label>Sévérité</label>
<select v-model="editingStep.severity">
<option value="">—</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
<template v-if="editingStep.parent">
<label>Branche (parent condition)</label>
<input v-model="editingStep.branch" placeholder="yes / no / dying_gasp / ..." />
</template>
</div>
<div class="af-modal-ftr">
<button class="af-btn af-btn-primary" @click="saveStep">Enregistrer</button>
<button class="af-btn af-btn-ghost" @click="editingStep = null">Annuler</button>
</div>
</div>
</div>
<!-- Prompt preview modal -->
<div v-if="showPrompt" class="af-overlay" @click.self="showPrompt = false">
<div class="af-modal af-modal-wide">
<div class="af-modal-hdr">
<span>System Prompt généré</span>
<button @click="showPrompt = false">&#x2715;</button>
</div>
<pre class="af-prompt-pre">{{ generatedPrompt }}</pre>
</div>
</div>
</template>
</q-page>
</template>
<!-- Recursive FlowNode component -->
<script>
import { defineComponent, h } from 'vue'
const severityColors = { info: '#3b82f6', warning: '#f59e0b', critical: '#ef4444' }
const typeIcons = { tool: '\u2699', condition: '\u2753', switch: '\u2194', respond: '\uD83D\uDCAC', action: '\u26A1', goto: '\u27A1' }
const FlowNode = defineComponent({
name: 'FlowNode',
props: ['step', 'intent', 'stepsByParent', 'branchesOf', 'depth'],
emits: ['edit', 'delete', 'add'],
setup (props, { emit }) {
return () => {
const s = props.step
const branches = props.branchesOf(s)
const sevColor = s.severity ? severityColors[s.severity] : null
const icon = typeIcons[s.type] || '\u25CB'
const children = []
// Render branch children
if (s.type === 'condition') {
for (const br of ['yes', 'no']) {
const brSteps = props.stepsByParent(props.intent.steps, s.id, br)
if (brSteps.length || true) {
children.push(h('div', { class: 'af-branch', key: br }, [
h('div', { class: `af-branch-label af-branch-${br}` }, br === 'yes' ? 'Oui' : 'Non'),
...brSteps.map(cs => h(FlowNode, { step: cs, intent: props.intent, stepsByParent: props.stepsByParent, branchesOf: props.branchesOf, depth: props.depth + 1, onEdit: (st) => emit('edit', st), onDelete: (id) => emit('delete', id), onAdd: (p, b) => emit('add', p, b) })),
h('button', { class: 'af-add-child', onClick: () => emit('add', s.id, br) }, '+'),
]))
}
}
} else if (s.type === 'switch') {
const allBranches = [...new Set(props.intent.steps.filter(c => c.parent === s.id).map(c => c.branch))].filter(Boolean)
for (const br of allBranches) {
const brSteps = props.stepsByParent(props.intent.steps, s.id, br)
children.push(h('div', { class: 'af-branch', key: br }, [
h('div', { class: 'af-branch-label af-branch-case' }, br),
...brSteps.map(cs => h(FlowNode, { step: cs, intent: props.intent, stepsByParent: props.stepsByParent, branchesOf: props.branchesOf, depth: props.depth + 1, onEdit: (st) => emit('edit', st), onDelete: (id) => emit('delete', id), onAdd: (p, b) => emit('add', p, b) })),
]))
}
children.push(h('button', { class: 'af-add-child', onClick: () => { const br = prompt('Nom de la branche (ex: dying_gasp)'); if (br) emit('add', s.id, br) } }, '+ branche'))
}
return h('div', { class: 'af-node-wrap' }, [
h('div', {
class: ['af-node', `af-node-${s.type}`],
style: sevColor ? `border-left: 3px solid ${sevColor}` : '',
onClick: () => emit('edit', s),
}, [
h('div', { class: 'af-node-head' }, [
h('span', { class: 'af-node-icon' }, icon),
h('span', { class: 'af-node-label' }, s.label || s.id),
h('span', { class: 'af-node-type' }, s.type),
h('button', { class: 'af-node-del', onClick: (e) => { e.stopPropagation(); emit('delete', s.id) } }, '\u2715'),
]),
s.tool ? h('div', { class: 'af-node-detail' }, `\u2699 ${s.tool}`) : null,
s.message ? h('div', { class: 'af-node-msg' }, s.message.length > 80 ? s.message.slice(0, 80) + '...' : s.message) : null,
s.action ? h('div', { class: 'af-node-detail af-node-action-detail' }, `\u26A1 ${s.action}`) : null,
s.note ? h('div', { class: 'af-node-note' }, s.note) : null,
]),
children.length ? h('div', { class: 'af-children' }, children) : null,
])
}
},
})
export { FlowNode }
</script>
<style>
.af-page { padding: 20px 24px; max-width: 1200px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.af-loading { padding: 40px; text-align: center; color: #94a3b8; }
.af-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.af-title { display: flex; align-items: center; gap: 12px; }
.af-title h1 { font-size: 22px; font-weight: 700; margin: 0; }
.af-icon { font-size: 28px; }
.af-subtitle { font-size: 13px; color: #64748b; }
.af-actions { display: flex; gap: 8px; }
.af-btn { padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: 1px solid #e2e8f0; background: #fff; transition: all .15s; }
.af-btn:hover { background: #f8fafc; }
.af-btn-primary { background: #6366f1; color: #fff; border-color: #6366f1; }
.af-btn-primary:hover { background: #4f46e5; }
.af-btn-primary:disabled { opacity: .5; cursor: default; }
.af-btn-ghost { background: transparent; border-color: #cbd5e1; }
.af-btn-sm { padding: 3px 10px; font-size: 12px; }
.af-btn-x { background: none; border: none; cursor: pointer; color: #94a3b8; font-size: 14px; padding: 2px 6px; }
.af-btn-x:hover { color: #ef4444; }
.af-persona { border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 18px; margin-bottom: 18px; cursor: pointer; background: #fafbfc; }
.af-persona:hover { border-color: #cbd5e1; }
.af-persona-hdr { font-size: 14px; margin-bottom: 6px; }
.af-persona-rules { display: flex; flex-wrap: wrap; gap: 6px; }
.af-rule-chip { font-size: 11px; background: #f1f5f9; padding: 3px 10px; border-radius: 12px; color: #475569; }
.af-persona-edit { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.af-persona-edit label { font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; }
.af-persona-edit input { padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; }
.af-rule-row { display: flex; gap: 6px; align-items: center; }
.af-rule-row input { flex: 1; }
.af-tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
.af-tab { padding: 7px 16px; border: 2px solid transparent; border-bottom-color: #e2e8f0; border-radius: 8px 8px 0 0; font-size: 13px; font-weight: 600; cursor: pointer; background: #f8fafc; color: #64748b; display: flex; align-items: center; gap: 6px; transition: all .15s; }
.af-tab:hover { background: #f1f5f9; }
.af-tab.active { background: #fff; border-color: currentColor; border-bottom-color: #fff; }
.af-tab-dot { width: 8px; height: 8px; border-radius: 50%; }
.af-tab-add { border: 2px dashed #cbd5e1; color: #94a3b8; }
.af-intent { border: 1px solid #e2e8f0; border-radius: 10px; padding: 18px; background: #fff; }
.af-intent-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.af-intent-label { font-size: 18px; font-weight: 700; border: none; outline: none; flex: 1; background: transparent; }
.af-intent-color { width: 28px; height: 28px; border: none; cursor: pointer; border-radius: 6px; }
.af-del-intent { font-size: 16px; }
.af-trigger { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.af-trigger label { font-size: 12px; font-weight: 600; color: #64748b; white-space: nowrap; }
.af-trigger input { flex: 1; padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; }
.af-tree { }
.af-tree-col { display: flex; flex-direction: column; gap: 4px; }
.af-node-wrap { margin-left: 0; }
.af-children { margin-left: 24px; padding-left: 16px; border-left: 2px solid #e2e8f0; display: flex; flex-direction: column; gap: 4px; margin-top: 4px; }
.af-branch { margin-top: 4px; }
.af-branch-label { font-size: 11px; font-weight: 700; text-transform: uppercase; padding: 2px 8px; border-radius: 4px; display: inline-block; margin-bottom: 4px; }
.af-branch-yes { background: #dcfce7; color: #166534; }
.af-branch-no { background: #fee2e2; color: #991b1b; }
.af-branch-case { background: #e0e7ff; color: #3730a3; }
.af-node { padding: 10px 14px; border: 1px solid #e2e8f0; border-radius: 8px; cursor: pointer; background: #fff; transition: all .15s; }
.af-node:hover { border-color: #6366f1; box-shadow: 0 1px 4px rgba(99,102,241,.12); }
.af-node-tool { background: #fefce8; border-color: #fde68a; }
.af-node-condition { background: #eff6ff; border-color: #bfdbfe; }
.af-node-switch { background: #f0fdf4; border-color: #bbf7d0; }
.af-node-respond { background: #faf5ff; border-color: #e9d5ff; }
.af-node-action { background: #fff1f2; border-color: #fecdd3; }
.af-node-goto { background: #f0f9ff; border-color: #bae6fd; }
.af-node-head { display: flex; align-items: center; gap: 8px; }
.af-node-icon { font-size: 16px; }
.af-node-label { font-size: 13px; font-weight: 600; flex: 1; }
.af-node-type { font-size: 10px; color: #94a3b8; text-transform: uppercase; font-weight: 600; }
.af-node-del { background: none; border: none; cursor: pointer; color: #cbd5e1; font-size: 12px; padding: 0 4px; }
.af-node-del:hover { color: #ef4444; }
.af-node-detail { font-size: 12px; color: #64748b; margin-top: 4px; }
.af-node-action-detail { color: #dc2626; }
.af-node-msg { font-size: 12px; color: #475569; margin-top: 4px; font-style: italic; white-space: pre-line; }
.af-node-note { font-size: 11px; color: #94a3b8; margin-top: 3px; padding: 2px 6px; background: #f8fafc; border-radius: 3px; }
.af-add-root, .af-add-child { padding: 6px 14px; border: 2px dashed #cbd5e1; border-radius: 8px; color: #94a3b8; font-size: 12px; cursor: pointer; background: transparent; margin-top: 6px; }
.af-add-root:hover, .af-add-child:hover { border-color: #6366f1; color: #6366f1; }
.af-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.af-modal { background: #fff; border-radius: 12px; width: 480px; max-width: 90vw; max-height: 85vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,.15); }
.af-modal-wide { width: 700px; }
.af-modal-hdr { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid #e2e8f0; font-weight: 600; }
.af-modal-hdr button { background: none; border: none; font-size: 18px; cursor: pointer; color: #64748b; }
.af-modal-body { padding: 18px; display: flex; flex-direction: column; gap: 8px; }
.af-modal-body label { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-top: 4px; }
.af-modal-body input, .af-modal-body select, .af-modal-body textarea { padding: 7px 10px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; font-family: inherit; }
.af-modal-body textarea { resize: vertical; }
.af-input-disabled { background: #f8fafc; color: #94a3b8; }
.af-row { display: flex; gap: 10px; }
.af-row > div { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.af-modal-ftr { padding: 12px 18px; border-top: 1px solid #e2e8f0; display: flex; gap: 8px; justify-content: flex-end; }
.af-prompt-pre { padding: 18px; font-size: 12px; line-height: 1.6; white-space: pre-wrap; max-height: 60vh; overflow-y: auto; background: #f8fafc; margin: 0; font-family: 'SF Mono', monospace; }
.af-json-wrap { margin-top: 12px; }
.af-json { width: 100%; min-height: 500px; padding: 14px; font-family: 'SF Mono', monospace; font-size: 12px; line-height: 1.5; border: 1px solid #e2e8f0; border-radius: 10px; resize: vertical; }
</style>