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>
151 lines
7.6 KiB
Vue
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>
|