feat: AI Desks — configurable agent system for news/technical/eco processing
- New ai_desks table with CRUD (get_all/by_type/upsert/delete) - ai_desks router: REST API + GET /signal-catalog (7 extensible signals) - News Desk: semantic dedup via AI (±N days window, system_prompt hint) - Technical Desk: 4 signal detectors driven by desk config (ma_cross, rsi_extreme, bb_squeeze, new_52w_extreme) - 3 more signals in catalog ready to enable: price_gap, volume_spike, macd_crossover - market_event_detector.py loads desk configs at runtime, falls back to legacy params - AIDesks.tsx: full editor UI with signal toggles, param sliders, instrument multi-select - Sidebar: Bot icon + /ai-desks route Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import ExternalSnapshot from './pages/ExternalSnapshot'
|
||||
import InstrumentDashboard from './pages/InstrumentDashboard'
|
||||
import CycleActions from './pages/CycleActions'
|
||||
import MarketEvents from './pages/MarketEvents'
|
||||
import AIDesks from './pages/AIDesks'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useCycleWatcher } from './hooks/useApi'
|
||||
|
||||
@@ -74,6 +75,7 @@ export default function App() {
|
||||
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
|
||||
<Route path="/market-events" element={<MarketEvents />} />
|
||||
<Route path="/cycle-actions" element={<CycleActions />} />
|
||||
<Route path="/ai-desks" element={<AIDesks />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot
|
||||
} from 'lucide-react'
|
||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||
import clsx from 'clsx'
|
||||
@@ -29,6 +29,7 @@ const nav = [
|
||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
||||
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Analysis' },
|
||||
{ to: '/market-events', icon: Radio, label: 'Market Events' },
|
||||
{ to: '/ai-desks', icon: Bot, label: 'AI Desks' },
|
||||
{ to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' },
|
||||
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
|
||||
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
||||
|
||||
599
frontend/src/pages/AIDesks.tsx
Normal file
599
frontend/src/pages/AIDesks.tsx
Normal file
@@ -0,0 +1,599 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Bot, Plus, Trash2, Save, ChevronDown, ChevronRight, ToggleLeft, ToggleRight } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SignalParam {
|
||||
type: 'int' | 'float' | 'pairs'
|
||||
label: string
|
||||
default: number | number[][]
|
||||
min?: number
|
||||
max?: number
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
interface SignalDef {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
params: Record<string, SignalParam>
|
||||
}
|
||||
|
||||
interface AIDesk {
|
||||
id?: number
|
||||
name: string
|
||||
type: 'news' | 'technical' | 'eco' | 'report'
|
||||
active: boolean
|
||||
system_prompt: string
|
||||
instruments: string[]
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
const ALL_INSTRUMENTS = [
|
||||
'SPY','QQQ','IWM','EEM','EFA',
|
||||
'GLD','SLV','USO','UNG','TLT',
|
||||
'HYG','EURUSD=X','USDJPY=X','GBPUSD=X',
|
||||
'VXX','AAPL','NVDA','GS','XOM','BTC-USD',
|
||||
]
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
news: 'News',
|
||||
technical: 'Technical',
|
||||
eco: 'Économique',
|
||||
report: 'Report',
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
news: 'text-blue-400 bg-blue-900/20 border-blue-700/40',
|
||||
technical: 'text-cyan-400 bg-cyan-900/20 border-cyan-700/40',
|
||||
eco: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/40',
|
||||
report: 'text-violet-400 bg-violet-900/20 border-violet-700/40',
|
||||
}
|
||||
|
||||
// ── Subcomponents ─────────────────────────────────────────────────────────────
|
||||
|
||||
function InstrumentSelector({
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
selected: string[]
|
||||
onChange: (v: string[]) => void
|
||||
}) {
|
||||
const toggle = (t: string) => {
|
||||
onChange(selected.includes(t) ? selected.filter(x => x !== t) : [...selected, t])
|
||||
}
|
||||
const all = selected.length === ALL_INSTRUMENTS.length
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-slate-400">Instruments surveillés</label>
|
||||
<button
|
||||
onClick={() => onChange(all ? [] : [...ALL_INSTRUMENTS])}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{all ? 'Tout désélectionner' : 'Tout sélectionner'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_INSTRUMENTS.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => toggle(t)}
|
||||
className={clsx(
|
||||
'px-2 py-0.5 rounded text-xs border transition-colors',
|
||||
selected.includes(t)
|
||||
? 'bg-blue-900/40 border-blue-600/60 text-blue-300'
|
||||
: 'bg-dark-800 border-slate-700/40 text-slate-500 hover:border-slate-600',
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function SignalToggle({
|
||||
signal,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
signal: SignalDef
|
||||
value: { enabled: boolean; [k: string]: any }
|
||||
onChange: (v: { enabled: boolean; [k: string]: any }) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const update = (key: string, val: any) => {
|
||||
onChange({ ...value, [key]: val })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
'rounded-lg border transition-colors',
|
||||
value.enabled
|
||||
? 'border-cyan-700/40 bg-cyan-900/10'
|
||||
: 'border-slate-700/30 bg-dark-800/60',
|
||||
)}>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<button
|
||||
onClick={() => onChange({ ...value, enabled: !value.enabled })}
|
||||
className="shrink-0"
|
||||
>
|
||||
{value.enabled
|
||||
? <ToggleRight className="w-5 h-5 text-cyan-400" />
|
||||
: <ToggleLeft className="w-5 h-5 text-slate-600" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={clsx('text-sm font-medium', value.enabled ? 'text-white' : 'text-slate-500')}>
|
||||
{signal.label}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 truncate">{signal.description}</div>
|
||||
</div>
|
||||
{value.enabled && Object.keys(signal.params).length > 0 && (
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
{open
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && value.enabled && (
|
||||
<div className="px-4 pb-3 space-y-2 border-t border-slate-700/20 pt-2">
|
||||
{Object.entries(signal.params).map(([key, p]) => {
|
||||
if (p.type === 'pairs') {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="text-xs text-slate-400 mb-1">{p.label}</div>
|
||||
<div className="text-xs text-slate-500 italic">
|
||||
{JSON.stringify(value[key] ?? p.default)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<label className="text-xs text-slate-400 w-28 shrink-0">{p.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={p.min}
|
||||
max={p.max}
|
||||
step={p.type === 'float' ? 0.01 : 1}
|
||||
value={value[key] ?? p.default}
|
||||
onChange={e => update(key, p.type === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value))}
|
||||
className="w-24 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function NewsConfig({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: Record<string, any>
|
||||
onChange: (c: Record<string, any>) => void
|
||||
}) {
|
||||
const set = (k: string, v: any) => onChange({ ...config, [k]: v })
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Score min d'impact</label>
|
||||
<input
|
||||
type="number" min={0} max={1} step={0.05}
|
||||
value={config.min_impact ?? 0.55}
|
||||
onChange={e => set('min_impact', parseFloat(e.target.value))}
|
||||
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Max events à évaluer</label>
|
||||
<input
|
||||
type="number" min={1} max={50}
|
||||
value={config.max_evaluate ?? 15}
|
||||
onChange={e => set('max_evaluate', parseInt(e.target.value))}
|
||||
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => set('dedup_enabled', !config.dedup_enabled)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{config.dedup_enabled
|
||||
? <ToggleRight className="w-5 h-5 text-blue-400" />
|
||||
: <ToggleLeft className="w-5 h-5 text-slate-600" />}
|
||||
</button>
|
||||
<span className="text-sm text-slate-300">Déduplication sémantique</span>
|
||||
{config.dedup_enabled && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="text-xs text-slate-500">Fenêtre ± jours</span>
|
||||
<input
|
||||
type="number" min={1} max={7}
|
||||
value={config.dedup_lookback_days ?? 2}
|
||||
onChange={e => set('dedup_lookback_days', parseInt(e.target.value))}
|
||||
className="w-14 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function EcoConfig({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: Record<string, any>
|
||||
onChange: (c: Record<string, any>) => void
|
||||
}) {
|
||||
const set = (k: string, v: any) => onChange({ ...config, [k]: v })
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Seuil z-score</label>
|
||||
<input
|
||||
type="number" min={0.5} max={5} step={0.1}
|
||||
value={config.z_threshold ?? 1.5}
|
||||
onChange={e => set('z_threshold', parseFloat(e.target.value))}
|
||||
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Jours lookback</label>
|
||||
<input
|
||||
type="number" min={1} max={30}
|
||||
value={config.days ?? 7}
|
||||
onChange={e => set('days', parseInt(e.target.value))}
|
||||
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Desk editor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DeskEditor({
|
||||
desk,
|
||||
catalog,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
desk: AIDesk
|
||||
catalog: SignalDef[]
|
||||
onSave: (d: AIDesk) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [d, setD] = useState<AIDesk>(desk)
|
||||
const set = (k: keyof AIDesk, v: any) => setD(prev => ({ ...prev, [k]: v }))
|
||||
const dirty = JSON.stringify(d) !== JSON.stringify(desk)
|
||||
|
||||
// Ensure signals config is initialized from catalog for technical desks
|
||||
useEffect(() => {
|
||||
if (d.type !== 'technical' || !catalog.length) return
|
||||
const signals = d.config.signals ?? {}
|
||||
let changed = false
|
||||
const next = { ...signals }
|
||||
for (const sig of catalog) {
|
||||
if (!next[sig.id]) {
|
||||
const defaults: Record<string, any> = { enabled: false }
|
||||
for (const [k, p] of Object.entries(sig.params)) {
|
||||
defaults[k] = p.default
|
||||
}
|
||||
next[sig.id] = defaults
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) setD(prev => ({ ...prev, config: { ...prev.config, signals: next } }))
|
||||
}, [catalog, d.type])
|
||||
|
||||
const updateSignal = (sigId: string, val: any) => {
|
||||
setD(prev => ({
|
||||
...prev,
|
||||
config: { ...prev.config, signals: { ...(prev.config.signals ?? {}), [sigId]: val } },
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
value={d.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
placeholder="Nom du desk"
|
||||
className="w-full bg-dark-900 border border-slate-700/40 rounded px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={d.type}
|
||||
onChange={e => set('type', e.target.value as AIDesk['type'])}
|
||||
className="bg-dark-900 border border-slate-700/40 rounded px-3 py-2 text-sm text-white"
|
||||
>
|
||||
{Object.entries(TYPE_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => set('active', !d.active)}
|
||||
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded border text-xs font-medium transition-colors',
|
||||
d.active
|
||||
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-400'
|
||||
: 'bg-dark-800 border-slate-700/30 text-slate-500',
|
||||
)}
|
||||
>
|
||||
{d.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
||||
{d.active ? 'Actif' : 'Inactif'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* System prompt */}
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">System prompt</label>
|
||||
<textarea
|
||||
value={d.system_prompt}
|
||||
onChange={e => set('system_prompt', e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Instructions spécifiques pour ce desk AI..."
|
||||
className="w-full bg-dark-900 border border-slate-700/40 rounded px-3 py-2 text-sm text-white resize-y font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Instruments */}
|
||||
<InstrumentSelector
|
||||
selected={d.instruments}
|
||||
onChange={v => set('instruments', v)}
|
||||
/>
|
||||
|
||||
{/* Type-specific config */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 mb-2">Configuration</div>
|
||||
{d.type === 'news' && (
|
||||
<NewsConfig
|
||||
config={d.config}
|
||||
onChange={c => set('config', c)}
|
||||
/>
|
||||
)}
|
||||
{d.type === 'eco' && (
|
||||
<EcoConfig
|
||||
config={d.config}
|
||||
onChange={c => set('config', c)}
|
||||
/>
|
||||
)}
|
||||
{d.type === 'technical' && catalog.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{catalog.map(sig => (
|
||||
<SignalToggle
|
||||
key={sig.id}
|
||||
signal={sig}
|
||||
value={d.config.signals?.[sig.id] ?? { enabled: false }}
|
||||
onChange={v => updateSignal(sig.id, v)}
|
||||
/>
|
||||
))}
|
||||
<div className="pt-1">
|
||||
<label className="text-xs text-slate-400 block mb-1">Lookback jours</label>
|
||||
<input
|
||||
type="number" min={1} max={30}
|
||||
value={d.config.lookback_days ?? 7}
|
||||
onChange={e => set('config', { ...d.config, lookback_days: parseInt(e.target.value) })}
|
||||
className="w-24 bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700/30">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs text-red-400 hover:bg-red-900/20 border border-transparent hover:border-red-800/40 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(d)}
|
||||
disabled={!dirty}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-4 py-1.5 rounded text-sm font-medium transition-colors',
|
||||
dirty
|
||||
? 'bg-blue-600 hover:bg-blue-500 text-white'
|
||||
: 'bg-dark-700 text-slate-600 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AIDesks() {
|
||||
const [desks, setDesks] = useState<AIDesk[]>([])
|
||||
const [catalog, setCatalog] = useState<SignalDef[]>([])
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const [dRes, cRes] = await Promise.all([
|
||||
fetch('/api/ai-desks'),
|
||||
fetch('/api/ai-desks/signal-catalog'),
|
||||
])
|
||||
const dData = await dRes.json()
|
||||
const cData = await cRes.json()
|
||||
setDesks(dData)
|
||||
setCatalog(cData)
|
||||
if (dData.length > 0 && selected === null) setSelected(0)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const showMsg = (ok: boolean, text: string) => {
|
||||
setMsg({ ok, text })
|
||||
setTimeout(() => setMsg(null), 3000)
|
||||
}
|
||||
|
||||
const handleSave = async (d: AIDesk) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const method = d.id ? 'PUT' : 'POST'
|
||||
const url = d.id ? `/api/ai-desks/${d.id}` : '/api/ai-desks'
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(d),
|
||||
})
|
||||
if (r.ok) {
|
||||
showMsg(true, 'Desk enregistré')
|
||||
await load()
|
||||
} else {
|
||||
const err = await r.json()
|
||||
showMsg(false, err.detail ?? 'Erreur sauvegarde')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (d: AIDesk) => {
|
||||
if (!d.id) return
|
||||
if (!confirm(`Supprimer le desk "${d.name}" ?`)) return
|
||||
const r = await fetch(`/api/ai-desks/${d.id}`, { method: 'DELETE' })
|
||||
if (r.ok) {
|
||||
showMsg(true, 'Desk supprimé')
|
||||
setSelected(null)
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
const handleNew = () => {
|
||||
const newDesk: AIDesk = {
|
||||
name: 'Nouveau Desk',
|
||||
type: 'news',
|
||||
active: true,
|
||||
system_prompt: '',
|
||||
instruments: [],
|
||||
config: {},
|
||||
}
|
||||
setDesks(prev => [...prev, newDesk])
|
||||
setSelected(desks.length)
|
||||
}
|
||||
|
||||
const currentDesk = selected !== null ? desks[selected] : null
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bot className="w-6 h-6 text-blue-400" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">AI Desks</h1>
|
||||
<p className="text-sm text-slate-500">Configurer les agents IA spécialisés</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Nouveau desk
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={clsx(
|
||||
'px-4 py-2 rounded-lg text-sm border',
|
||||
msg.ok
|
||||
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-300'
|
||||
: 'bg-red-900/30 border-red-700/40 text-red-300',
|
||||
)}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-slate-500 text-sm py-8 text-center">Chargement...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[240px_1fr] gap-4">
|
||||
{/* Desk list */}
|
||||
<div className="space-y-1">
|
||||
{desks.map((d, i) => (
|
||||
<button
|
||||
key={d.id ?? `new-${i}`}
|
||||
onClick={() => setSelected(i)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2.5 rounded-lg border transition-colors',
|
||||
selected === i
|
||||
? 'bg-blue-900/30 border-blue-700/40 text-white'
|
||||
: 'bg-dark-800/60 border-slate-700/30 text-slate-400 hover:text-white hover:border-slate-600/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
'inline-block w-1.5 h-1.5 rounded-full',
|
||||
d.active ? 'bg-emerald-400' : 'bg-slate-600',
|
||||
)} />
|
||||
<span className="text-sm font-medium truncate">{d.name}</span>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'inline-block mt-1 px-1.5 py-0.5 rounded text-xs border',
|
||||
TYPE_COLORS[d.type] ?? 'text-slate-400 bg-dark-700 border-slate-700/30',
|
||||
)}>
|
||||
{TYPE_LABELS[d.type] ?? d.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
{currentDesk ? (
|
||||
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-5">
|
||||
<DeskEditor
|
||||
key={currentDesk.id ?? `new-${selected}`}
|
||||
desk={currentDesk}
|
||||
catalog={catalog}
|
||||
onSave={handleSave}
|
||||
onDelete={() => handleDelete(currentDesk)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 flex items-center justify-center text-slate-600 text-sm">
|
||||
Sélectionner un desk ou en créer un nouveau
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user