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>
488 lines
24 KiB
Vue
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">⚡</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">🗑</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">✕</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="<"><</option><option value=">">></option>
|
|
<option value="<="><=</option><option value=">=">>=</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">✕</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>
|