feat: causal lab — chip filters, editor zoom, AI modify prompt

Library:
- Color chip filters: category (with per-category colors), sub_type, instrument
- Date range filter (created_at) + text search
- Category labels unified to cover event_calendar, fundamental, calendar_event etc.
- Editer button on selected template → opens in editor tab
- Show created_at + created_by in template detail

Editor:
- Accepts initialId prop so Library can pre-load a template
- AI Modify zone: textarea prompt + button calls /api/causal-lab/template/{id}/ai-modify
- Returns modified graph_json from GPT-4o, applied live — user reviews then saves

Backend:
- New POST /api/causal-lab/template/{id}/ai-modify endpoint
  - Sends current graph_json + user prompt to GPT-4o
  - Returns validated modified graph_json (nodes/edges required)
  - Does not auto-save (user controls save)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-30 09:04:15 +02:00
parent 589faa633b
commit 40d6f14901
2 changed files with 369 additions and 31 deletions

View File

@@ -1847,3 +1847,90 @@ def generate_theory(template_id: int):
except Exception as e:
logger.error(f"[causal_lab] generate_theory {template_id}: {e}")
raise HTTPException(500, str(e))
# ── AI graph modifier ─────────────────────────────────────────────────────────
class AiModifyRequest(BaseModel):
prompt: str
current_graph: dict = {} # client can pass current in-memory state
@router.post("/api/causal-lab/template/{template_id}/ai-modify")
def ai_modify_template(template_id: int, body: AiModifyRequest):
"""
GPT-4o modifie un graph_json selon un prompt utilisateur.
Retourne le graph_json modifié sans sauvegarder — l'utilisateur valide puis sauvegarde.
"""
try:
from services.database import get_conn, get_config
from services.causal_graphs import get_template
import openai
key = get_config("openai_api_key") or ""
if not key:
raise HTTPException(400, "Clé OpenAI manquante dans la configuration")
conn = get_conn()
t = get_template(conn, template_id)
conn.close()
if not t:
raise HTTPException(404, f"Template {template_id} non trouvé")
# Use the graph sent by client (latest in-memory edits) or fall back to DB
gj = body.current_graph if body.current_graph.get("nodes") else t["graph_json"]
system_prompt = (
"You are a causal graph editor for the GeoOptions macro-finance platform.\n"
"You receive a causal graph in JSON and a modification request in natural language.\n"
"Return ONLY the complete modified graph_json as a valid JSON object — no explanation, no markdown.\n\n"
"Graph format:\n"
"{\n"
' "nodes": [{"id": str, "label": str, "type": "macro_event|observable|latent|market_asset",\n'
' "x": int, "y": int, "formula": str_opt, "unit": str_opt, "instrument": str_opt}],\n'
' "edges": [{"from": str, "to": str, "style": "solid|dashed", "strength": 1|2|3,\n'
' "sign": "positive|negative|neutral", "label": str_opt,\n'
' "lag_days": int_opt, "decay_days": float_opt}],\n'
' "coefficients": {"key": {"value": float, "calibrated": null, "description": str}},\n'
' "instruments": [str],\n'
' "input_mapping": {"node_id": {"source": "user_input|surprise|actual_value|impact_score_scaled",\n'
' "key": str_opt, "field": str_opt}}\n'
"}\n\n"
"Rules:\n"
"- Preserve existing node x/y positions unless layout is clearly broken\n"
"- All edge from/to must reference valid node ids\n"
"- instruments list must include instrument field of all market_asset nodes\n"
"- Return the COMPLETE graph_json (not just the changed parts)"
)
user_prompt = (
f'Current graph — template "{t["name"]}" ({t["category"]}, sub_type: {t.get("sub_type","")}):\n'
f"{json.dumps(gj, ensure_ascii=False, indent=2)}\n\n"
f"Modification request:\n{body.prompt}\n\n"
"Return the complete modified graph_json as JSON only."
)
client = openai.OpenAI(api_key=key)
resp = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
response_format={"type": "json_object"},
temperature=0.25,
max_tokens=3000,
)
raw = resp.choices[0].message.content or "{}"
modified = json.loads(raw)
if "nodes" not in modified or "edges" not in modified:
raise ValueError("L'IA n'a pas retourné un graph_json valide (nodes/edges manquants)")
return {"graph_json": modified, "template_name": t["name"]}
except HTTPException:
raise
except Exception as e:
logger.error(f"[causal_lab] ai_modify_template {template_id}: {e}")
raise HTTPException(500, str(e))

View File

@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'
import {
FlaskConical, Library, Zap, BarChart3, RefreshCw, Edit3,
Sliders, Brain, Search, CheckCircle, XCircle, Minus, AlertCircle,
Plus, Trash2, Save, Copy, Info,
Plus, Trash2, Save, Copy, Info, Wand2, ExternalLink, Filter, X,
} from 'lucide-react'
import clsx from 'clsx'
@@ -50,7 +50,7 @@ interface Template {
id: number; name: string; category: string; sub_type: string
instruments: string[]; description: string; graph_json: GraphJson
ai_rationale: string; calibration_json: Record<string, unknown>
heuristic_ver: number; created_by?: string
heuristic_ver: number; created_by?: string; created_at?: string
}
interface MarketEvent {
id: number; name: string; category: string; sub_type: string
@@ -120,8 +120,38 @@ function fmtLag(min: number): string {
const fmtDecay = (d: number) => `${d}j`
const CAT_LABELS: Record<string, string> = {
macro_us: 'Macro US', macro_eu: 'Macro EU', geopolitical: 'Géopolitique',
commodity: 'Matières premières', report: 'Report', sentiment: 'Sentiment',
// Template categories
macro_us: 'Macro US',
macro_eu: 'Macro EU',
geopolitical: 'Géopolitique',
commodity: 'Matières 1ères',
report: 'Report',
sentiment: 'Sentiment',
// Market event categories (aligned)
event_calendar: 'Calendrier',
calendar_event: 'Calendrier',
fundamental: 'Fondamental',
}
// Chip color per category (inline styles to survive Tailwind purge)
const CAT_CHIP: Record<string, { bg: string; color: string; border: string }> = {
macro_us: { bg: '#1e3a5f', color: '#93c5fd', border: '#3b82f6' },
macro_eu: { bg: '#2d1b69', color: '#c4b5fd', border: '#8b5cf6' },
event_calendar: { bg: '#0c3535', color: '#67e8f9', border: '#06b6d4' },
calendar_event: { bg: '#0c3535', color: '#67e8f9', border: '#06b6d4' },
geopolitical: { bg: '#422006', color: '#fcd34d', border: '#d97706' },
commodity: { bg: '#14352a', color: '#6ee7b7', border: '#10b981' },
report: { bg: '#1e293b', color: '#94a3b8', border: '#475569' },
sentiment: { bg: '#4a1942', color: '#f9a8d4', border: '#ec4899' },
fundamental: { bg: '#1e1b4b', color: '#a5b4fc', border: '#6366f1' },
}
const chipStyle = (cat: string, active: boolean) => {
const c = CAT_CHIP[cat] ?? { bg: '#1e293b', color: '#94a3b8', border: '#475569' }
return {
background: active ? c.bg : 'transparent',
color: active ? c.color : '#64748b',
border: `1px solid ${active ? c.border : '#334155'}`,
}
}
const NODE_TYPES = ['macro_event', 'observable', 'latent', 'market_asset'] as const
const NODE_TYPE_LABELS: Record<string, string> = {
@@ -366,10 +396,21 @@ function GraphLegend() {
// ── Tab: Bibliothèque ─────────────────────────────────────────────────────────
function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }) {
function TabLibrary({
initialTemplateId,
onOpenEditor,
}: {
initialTemplateId?: number | null
onOpenEditor: (id: number) => void
}) {
const [templates, setTemplates] = useState<Template[]>([])
const [selected, setSelected] = useState<Template | null>(null)
const [catFilter, setCatFilter] = useState('')
const [subFilter, setSubFilter] = useState('')
const [instFilter, setInstFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [search, setSearch] = useState('')
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [savingLag, setSavingLag] = useState(false)
@@ -384,14 +425,14 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
useEffect(() => {
setLoading(true); setApiError(null)
api(`/api/causal-lab/templates${catFilter ? `?category=${catFilter}` : ''}`)
api('/api/causal-lab/templates')
.then(data => {
setTemplates(data)
if (data.length === 0) api('/api/causal-lab/debug').then(setDebugInfo).catch(() => {})
})
.catch(e => setApiError(String(e)))
.finally(() => setLoading(false))
}, [catFilter])
}, [])
// Auto-select template when arriving from a deep-link (?template=ID)
useEffect(() => {
@@ -485,17 +526,117 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
} finally { setDeleting(false) }
}
const cats = [...new Set(templates.map(t => t.category))]
// Derive filter options from loaded templates
const cats = [...new Set(templates.map(t => t.category))].sort()
const subs = catFilter
? [...new Set(templates.filter(t => t.category === catFilter).map(t => t.sub_type).filter(Boolean))].sort()
: []
const insts = [...new Set(templates.flatMap(t => t.instruments))].sort()
// Client-side filtered list
const visible = templates.filter(t => {
if (catFilter && t.category !== catFilter) return false
if (subFilter && t.sub_type !== subFilter) return false
if (instFilter && !t.instruments.includes(instFilter)) return false
if (dateFrom && t.created_at && t.created_at < dateFrom) return false
if (dateTo && t.created_at && t.created_at > dateTo + 'T23:59') return false
if (search && !t.name.toLowerCase().includes(search.toLowerCase())
&& !(t.description ?? '').toLowerCase().includes(search.toLowerCase())) return false
return true
})
return (
<div className="flex gap-4">
<div className="w-72 flex-shrink-0">
<select value={catFilter} onChange={e => setCatFilter(e.target.value)}
className="w-full mb-3 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300">
<option value="">Toutes les catégories</option>
{cats.map(c => <option key={c} value={c}>{CAT_LABELS[c] ?? c}</option>)}
</select>
<div className="flex flex-col gap-3">
{/* ── Filter bar ── */}
<div className="bg-dark-700/60 rounded-lg border border-slate-700/30 p-3 space-y-2">
{/* Category chips */}
<div className="flex flex-wrap gap-1.5 items-center">
<Filter className="w-3 h-3 text-slate-500 shrink-0" />
<button
onClick={() => { setCatFilter(''); setSubFilter('') }}
className="text-xs px-2.5 py-1 rounded-full border transition-all"
style={chipStyle('', catFilter === '')}>
Tout
</button>
{cats.map(c => (
<button key={c}
onClick={() => { setCatFilter(catFilter === c ? '' : c); setSubFilter('') }}
className="text-xs px-2.5 py-1 rounded-full border transition-all"
style={chipStyle(c, catFilter === c)}>
{CAT_LABELS[c] ?? c}
</button>
))}
</div>
{/* Sub-type chips — only if category selected and has sub-types */}
{subs.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center pl-4">
<span className="text-xs text-slate-600"></span>
<button
onClick={() => setSubFilter('')}
className="text-xs px-2 py-0.5 rounded-full border transition-all"
style={{ background: subFilter === '' ? '#1e293b' : 'transparent', color: subFilter === '' ? '#94a3b8' : '#475569', border: '1px solid ' + (subFilter === '' ? '#475569' : '#1e293b') }}>
Tous
</button>
{subs.map(s => (
<button key={s}
onClick={() => setSubFilter(subFilter === s ? '' : s)}
className="text-xs px-2 py-0.5 rounded-full border transition-all"
style={{ background: subFilter === s ? '#1e293b' : 'transparent', color: subFilter === s ? '#e2e8f0' : '#475569', border: '1px solid ' + (subFilter === s ? '#64748b' : '#1e293b') }}>
{s}
</button>
))}
</div>
)}
{/* Instrument + date row */}
<div className="flex flex-wrap gap-2 items-center">
{/* Instrument chips */}
<div className="flex flex-wrap gap-1 items-center">
<span className="text-xs text-slate-600">Inst:</span>
{insts.map(i => (
<button key={i}
onClick={() => setInstFilter(instFilter === i ? '' : i)}
className="text-xs px-2 py-0.5 rounded border transition-all"
style={{ background: instFilter === i ? '#14352a' : 'transparent', color: instFilter === i ? '#6ee7b7' : '#475569', border: '1px solid ' + (instFilter === i ? '#10b981' : '#1e293b') }}>
{i}
</button>
))}
</div>
{/* Date range */}
<div className="flex items-center gap-1.5 ml-auto">
<span className="text-xs text-slate-600">Créé:</span>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="bg-dark-800 border border-slate-700 rounded px-1.5 py-0.5 text-xs text-slate-400 w-32" />
<span className="text-slate-600 text-xs"></span>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="bg-dark-800 border border-slate-700 rounded px-1.5 py-0.5 text-xs text-slate-400 w-32" />
{(dateFrom || dateTo) && (
<button onClick={() => { setDateFrom(''); setDateTo('') }}
className="text-slate-600 hover:text-slate-400">
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="w-3 h-3 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-600" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Rechercher par nom / description…"
className="w-full bg-dark-800 border border-slate-700 rounded pl-7 pr-3 py-1.5 text-xs text-slate-300 placeholder:text-slate-600" />
{search && (
<button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400">
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{/* ── Main content: list + detail ── */}
<div className="flex gap-4">
<div className="w-64 flex-shrink-0">
{apiError && (
<div className="mb-2 p-3 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300 break-all">
<div className="font-bold mb-1">Erreur API</div>{apiError}
@@ -510,33 +651,52 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
{loading
? <div className="text-slate-500 text-xs p-4">Chargement</div>
: <div className="space-y-1 overflow-y-auto max-h-[calc(100vh-230px)]">
{templates.map(t => (
: <>
<div className="text-xs text-slate-600 mb-1.5">{visible.length} graphe{visible.length !== 1 ? 's' : ''}</div>
<div className="space-y-1 overflow-y-auto max-h-[calc(100vh-400px)]">
{visible.map(t => (
<button key={t.id} onClick={() => selectTemplate(t)}
className={clsx('w-full text-left px-3 py-2.5 rounded border text-xs transition-all',
selected?.id === t.id
? 'bg-blue-900/30 border-blue-500/50 text-blue-200'
: 'bg-dark-700 border-slate-700/50 text-slate-300 hover:border-slate-500')}>
<div className="font-medium truncate">{t.name}</div>
<div className="text-slate-500 mt-0.5 flex items-center gap-2">
<span className="px-1 rounded bg-slate-700/50">{CAT_LABELS[t.category] ?? t.category}</span>
<span>{t.instruments.join(', ')}</span>
<div className="text-slate-500 mt-0.5 flex items-center gap-1.5 flex-wrap">
<span className="px-1.5 py-px rounded-sm text-[10px]"
style={chipStyle(t.category, true)}>
{CAT_LABELS[t.category] ?? t.category}
</span>
{t.sub_type && <span className="text-slate-600 text-[10px]">{t.sub_type}</span>}
<span className="text-slate-600">{t.instruments.join(', ')}</span>
</div>
</button>
))}
</div>
</>
}
</div>
</div>
{selected ? (
<div className="flex-1 overflow-y-auto space-y-4 min-w-0 max-h-[calc(100vh-230px)]">
<div className="flex-1 overflow-y-auto space-y-4 min-w-0 max-h-[calc(100vh-380px)]">
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<div className="flex items-start justify-between mb-2">
<div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold">{selected.name}</h3>
<p className="text-slate-400 text-xs mt-1">{selected.description}</p>
{selected.created_at && (
<p className="text-slate-600 text-xs mt-1">
Créé le {new Date(selected.created_at).toLocaleDateString('fr-FR')}
{selected.created_by && ` · par ${selected.created_by}`}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
<button
onClick={() => onOpenEditor(selected.id)}
title="Modifier dans l'éditeur"
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-violet-800/40 hover:bg-violet-700/50 text-violet-300 border border-violet-700/40 text-xs font-medium transition-all">
<ExternalLink className="w-3 h-3" /> Éditer
</button>
<span className="text-xs px-2 py-1 rounded bg-purple-900/30 text-purple-300 border border-purple-700/40">
v{selected.heuristic_ver}
</span>
@@ -555,7 +715,15 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
{selected.ai_rationale}
</div>
)}
<div className="mt-3 flex gap-2 flex-wrap">
<div className="mt-3 flex gap-2 flex-wrap items-center">
<span className="text-xs px-2 py-0.5 rounded border" style={chipStyle(selected.category, true)}>
{CAT_LABELS[selected.category] ?? selected.category}
</span>
{selected.sub_type && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-700/40 text-slate-400 border border-slate-600/40">
{selected.sub_type}
</span>
)}
{selected.instruments.map(i => (
<span key={i} className="text-xs px-2 py-0.5 rounded bg-emerald-900/20 text-emerald-400 border border-emerald-700/30">{i}</span>
))}
@@ -728,6 +896,7 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
Sélectionnez un template dans la liste
</div>
)}
</div>{/* end flex list+detail */}
</div>
)
}
@@ -750,7 +919,7 @@ const BLANK: EditorState = {
const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
function TabEditor() {
function TabEditor({ initialId }: { initialId?: number | null }) {
const [templates, setTemplates] = useState<Template[]>([])
const [state, setState] = useState<EditorState>(BLANK)
const [selNode, setSelNode] = useState<string | null>(null)
@@ -763,6 +932,9 @@ function TabEditor() {
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
const [dataSources, setDS] = useState<DataSources>({ prices: [], macro_series: [], ff_events: [] })
const [aiPrompt, setAiPrompt] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState<string | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
useEffect(() => {
@@ -770,9 +942,13 @@ function TabEditor() {
api('/api/causal-lab/data-sources').then(setDS).catch(() => {})
}, [])
function loadTemplate(id: number) {
const t = templates.find(x => x.id === id)
if (!t) return
// Load template when arriving from Library "Éditer" button
useEffect(() => {
if (!initialId) return
api(`/api/causal-lab/template/${initialId}`).then(applyTemplateToState).catch(() => {})
}, [initialId])
function applyTemplateToState(t: Template) {
setState({
templateId: t.id, name: t.name, category: t.category, subType: t.sub_type,
description: t.description, instruments: t.instruments, aiRationale: t.ai_rationale,
@@ -784,6 +960,36 @@ function TabEditor() {
setSelNode(null)
}
function loadTemplate(id: number) {
const t = templates.find(x => x.id === id)
if (t) applyTemplateToState(t)
}
async function applyAiModify() {
if (!aiPrompt.trim()) return
setAiLoading(true); setAiError(null)
try {
const r = await api(`/api/causal-lab/template/${state.templateId ?? 0}/ai-modify`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: aiPrompt, current_graph: graphJson }),
})
const gj: GraphJson = r.graph_json
setState(s => ({
...s,
nodes: gj.nodes || s.nodes,
edges: gj.edges || s.edges,
coefficients: gj.coefficients || s.coefficients,
inputMapping: gj.input_mapping || s.inputMapping,
instruments: gj.instruments || s.instruments,
}))
setMsg({ ok: true, text: 'Graphe modifié par l\'IA — vérifiez puis sauvegardez.' })
} catch (e) {
setAiError(String(e))
} finally {
setAiLoading(false)
}
}
function resetNew() { setState(BLANK); setSelNode(null) }
const graphJson: GraphJson = {
@@ -1438,6 +1644,45 @@ function TabEditor() {
/>
</div>
<GraphLegend />
{/* AI Modification prompt */}
<div className="bg-dark-700 rounded-lg p-4 border border-violet-900/40">
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
<Wand2 className="w-3.5 h-3.5 text-violet-400" /> Modifier via IA
</h4>
{!state.templateId && (
<p className="text-xs text-slate-500 mb-2 italic">
Sauvegardez d'abord le template pour activer la modification IA.
</p>
)}
<textarea
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
disabled={aiLoading}
placeholder={
"Décrivez les modifications souhaitées...\n" +
"Ex: Ajoute un nœud latent 'risk_aversion' entre Fed OIS et EUR/USD, avec un lien négatif et lag 2j."
}
rows={5}
className="w-full bg-dark-800 border border-slate-600 rounded px-3 py-2 text-xs text-slate-200 placeholder:text-slate-600 resize-none focus:border-violet-600 focus:outline-none"
/>
<div className="flex items-center gap-3 mt-2">
<button
onClick={applyAiModify}
disabled={aiLoading || !aiPrompt.trim() || !state.templateId}
className="flex items-center gap-2 px-4 py-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded text-xs font-medium text-white transition-all">
{aiLoading
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Génération</>
: <><Wand2 className="w-3.5 h-3.5" /> Appliquer</>}
</button>
{aiLoading && <span className="text-xs text-violet-400 animate-pulse">GPT-4o réécrit le graphe</span>}
</div>
{aiError && (
<div className="mt-2 p-2 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300">
{aiError}
</div>
)}
</div>
</div>
</div>
)
@@ -1826,6 +2071,12 @@ export default function CausalLab() {
const initialTemplateId = searchParams.get('template') ? parseInt(searchParams.get('template')!) : null
const initialEventId = searchParams.get('event') ? parseInt(searchParams.get('event')!) : null
const [tab, setTab] = useState(initialEventId ? 'analyze' : 'library')
const [editorInitId, setEditorInitId] = useState<number | null>(null)
function openInEditor(id: number) {
setEditorInitId(id)
setTab('editor')
}
return (
<div className="p-6 min-h-screen">
@@ -1851,8 +2102,8 @@ export default function CausalLab() {
</div>
<div className="bg-dark-800 rounded-xl p-5 border border-slate-700/30">
{tab === 'library' && <TabLibrary initialTemplateId={initialTemplateId} />}
{tab === 'editor' && <TabEditor />}
{tab === 'library' && <TabLibrary initialTemplateId={initialTemplateId} onOpenEditor={openInEditor} />}
{tab === 'editor' && <TabEditor initialId={editorInitId} />}
{tab === 'analyze' && <TabAnalyze initialEventId={initialEventId} />}
{tab === 'calib' && <TabCalibration />}
</div>