gigafibre-fsm/apps/ops/src/components/settings/AiRoutingCard.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

151 lines
7.6 KiB
Vue

<template>
<div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs">
<q-icon name="hub" size="20px" color="deep-purple-6" class="q-mr-sm" />
<span class="text-subtitle1 text-weight-bold">Modèles IA routage & repli</span>
<q-space />
<q-spinner v-if="loading" size="16px" color="deep-purple-6" />
<q-btn flat round dense icon="refresh" size="sm" @click="load"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
</div>
<div class="text-caption text-grey-6 q-mb-md">
Choisis le modèle par tâche. <b>Gemini</b> reste le <b>repli automatique</b> si le modèle local échoue ou est indisponible. Le plan de contrôle (scripts, solveur) reste déterministe — l'IA ne fait que proposer.
</div>
<!-- Profils -->
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-12 col-sm-6" v-for="p in profiles" :key="p.name">
<div class="prof-card" :class="{ avail: p.available }">
<div class="row items-center no-wrap">
<q-icon :name="p.name === 'gemini' ? 'cloud' : 'dns'" :color="p.available ? 'deep-purple-5' : 'grey-5'" class="q-mr-sm" />
<div>
<div class="text-weight-medium">{{ p.label }}</div>
<div class="text-caption text-grey-6">{{ p.model || '—' }}<span v-if="p.base"> · {{ p.base }}</span></div>
</div>
<q-space />
<q-badge :color="p.available ? 'green-2' : 'grey-3'" :text-color="p.available ? 'green-9' : 'grey-7'">{{ p.available ? 'disponible' : 'indisponible' }}</q-badge>
</div>
</div>
</div>
</div>
<!-- Config du profil local -->
<div class="local-cfg q-mb-md">
<div class="text-subtitle2 q-mb-xs">Profil local (vLLM / Ollama → QWEN / Hermes)</div>
<div class="row q-col-gutter-sm">
<q-input class="col-12 col-sm-6" dense outlined v-model="local.baseUrl" label="URL base (OpenAI-compat)" placeholder="http://gpu-host:8000/v1/" hint="Slash final requis" />
<q-input class="col-12 col-sm-6" dense outlined v-model="local.model" label="Modèle" placeholder="Qwen/Qwen2.5-14B-Instruct" />
</div>
<div v-if="!localKeySet" class="text-caption text-warning q-mt-xs">
<q-icon name="warning" size="14px" /> Clé absente : définis <code>AI_LOCAL_API_KEY</code> dans <code>/opt/targo-hub/.env</code> (les secrets restent côté serveur, jamais dans l'UI), puis recrée le conteneur.
</div>
<div class="row items-center q-gutter-sm q-mt-sm">
<q-btn dense outline color="deep-purple-6" icon="network_check" label="Tester la connexion" no-caps :loading="testing" :disable="!localAvailable" @click="testLocal" />
<span v-if="testResult" :class="testResult.ok ? 'text-green-8' : 'text-negative'" class="text-caption">
<q-icon :name="testResult.ok ? 'check_circle' : 'error'" size="14px" />
{{ testResult.ok ? `OK · ${testResult.model} · ${testResult.latency_ms} ms` : ('Échec : ' + testResult.error) }}
</span>
<span v-else-if="!localAvailable" class="text-caption text-grey-6">Configure base + modèle + clé .env pour activer.</span>
</div>
</div>
<!-- Routage par tâche -->
<div class="text-subtitle2 q-mb-xs">Routage par tâche</div>
<div v-for="t in tasks" :key="t" class="row items-center q-py-xs q-px-sm route-row">
<q-icon :name="taskIcon(t)" size="18px" color="grey-7" class="q-mr-sm" />
<div class="col">
<div class="text-body2">{{ taskLabel(t) }}</div>
<div v-if="routing[t] && routing[t].wanted === 'local' && routing[t].effective !== 'local'" class="text-caption text-warning">↳ replié sur Gemini (local indisponible)</div>
</div>
<q-btn-toggle v-model="wanted[t]" :options="toggleOptions" dense unelevated no-caps toggle-color="deep-purple-6" color="grey-3" text-color="grey-8" size="sm" />
</div>
<div class="row justify-end q-mt-md">
<q-btn unelevated color="deep-purple-6" icon="save" label="Enregistrer le routage" no-caps :loading="saving" @click="save" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const $q = useQuasar()
const loading = ref(false)
const saving = ref(false)
const profiles = ref([])
const tasks = ref([])
const routing = ref({})
const wanted = reactive({})
const local = reactive({ baseUrl: '', model: '' })
const localKeySet = ref(false)
const testing = ref(false)
const testResult = ref(null)
const TASK_LABELS = {
triage: 'Triage courriels', nl: 'Copilote (langage naturel)', split: 'Scinder une conversation',
sms: 'Répondeur SMS', roster: 'Copilote planification', dispatch: 'Dispatch (suggestions IA)',
diagnose: 'Diagnostic réseau', outage: 'Analyse de panne',
}
const TASK_ICONS = {
triage: 'mail', nl: 'auto_awesome', split: 'call_split', sms: 'sms',
roster: 'event', dispatch: 'local_shipping', diagnose: 'troubleshoot', outage: 'warning',
}
function taskLabel (t) { return TASK_LABELS[t] || t }
function taskIcon (t) { return TASK_ICONS[t] || 'smart_toy' }
const localAvailable = computed(() => (profiles.value.find(p => p.name === 'local') || {}).available)
const toggleOptions = computed(() => [
{ label: 'Gemini', value: 'gemini' },
{ label: 'Local', value: 'local', disable: !localAvailable.value },
])
async function load () {
loading.value = true
try {
const r = await fetch(`${HUB_URL}/ai/route-status`)
if (r.ok) {
const d = await r.json()
profiles.value = d.profiles || []
tasks.value = d.tasks || []
routing.value = d.routing || {}
for (const t of tasks.value) wanted[t] = (d.routing[t] && d.routing[t].wanted) || 'gemini'
const loc = profiles.value.find(p => p.name === 'local') || {}
local.baseUrl = loc.base ? (loc.base.endsWith('/') ? loc.base : loc.base + '/') : ''
local.model = loc.model || ''
localKeySet.value = !!loc.key_set
}
} catch (e) { /* */ } finally { loading.value = false }
}
async function save () {
saving.value = true
try {
const routes = {}
for (const t of tasks.value) routes[t] = wanted[t]
const r = await fetch(`${HUB_URL}/ai/routes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routes, local: { baseUrl: local.baseUrl, model: local.model } }) })
if (r.ok) { const d = await r.json(); profiles.value = d.profiles || profiles.value; routing.value = d.routing || routing.value; $q.notify({ type: 'positive', message: 'Routage IA enregistré' }) }
else $q.notify({ type: 'negative', message: 'Échec sauvegarde' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { saving.value = false }
}
async function testLocal () {
testing.value = true; testResult.value = null
try {
const r = await fetch(`${HUB_URL}/ai/route-test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile: 'local' }) })
testResult.value = await r.json()
} catch (e) { testResult.value = { ok: false, error: e.message } } finally { testing.value = false }
}
onMounted(load)
</script>
<style scoped>
.prof-card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; background: #fafafd; }
.prof-card.avail { border-color: #c4b5fd; background: #f5f3ff; }
.local-cfg { border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; background: #fafbff; }
.route-row { border-bottom: 1px solid #f1f5f9; border-radius: 6px; transition: background .1s; }
.route-row:hover { background: #f5f3ff; }
code { background: #eef2ff; padding: 1px 5px; border-radius: 4px; font-size: .82em; }
</style>