gigafibre-fsm/apps/ops/src/components/shared/OrchestratorDialog.vue
louispaulb 0372176fc8 feat(assistant): vrai CHAT multi-tours — on peut répondre aux questions de l'assistant
Avant : l'assistant posait une question (« Souhaitez-vous que je crée… ? ») mais le dialogue était mono-coup — impossible
de répondre (pas de fil, chaque envoi repartait de zéro). Maintenant c'est une conversation :
- Backend staff-agent.plan(text, email, history) : accepte les tours précédents (user/assistant, 10 derniers) → contexte ;
  POST /staff-agent {text, history}.
- OrchestratorDialog : fil de bulles (user/assistant), le champ se vide après envoi pour RÉPONDRE, historique renvoyé à
  chaque tour, bouton « Envoyer » en mode conversation. Le plan (actions à confirmer) suit le dernier tour.
Vérifié : (backend) « vérifier signal C-LPB4 » → diagnostic (réparation, 12 techs/431h) ; « oui, crée » AVEC history →
propose create_job(C-LPB4, Réparation). (UI) 4 bulles [user,assistant,user,assistant], champ vidé, action « Créer un job »
affichée, 0 erreur console. Déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:07:55 -04:00

305 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<q-dialog v-model="open" @hide="reset">
<q-card style="min-width:540px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="bolt" color="deep-purple-6" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">{{ title }}</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<!-- Dictée / texte -->
<div class="row items-end q-gutter-sm">
<q-input v-model="text" type="textarea" autogrow :rows="2" outlined class="col" autofocus
label="Dicte ou écris (ex. « Crée un ticket de facture non payée pour Louis-Paul, texto au client qu'on attend le paiement »)"
@keyup.enter.exact.prevent="plan" />
<q-btn round :color="listening ? 'red-6' : 'deep-purple-5'" :icon="listening ? 'mic' : 'mic_none'" :loading="planning" @click="toggleMic">
<q-tooltip>{{ micSupported ? (listening ? 'Arrêter la dictée' : 'Dicter à voix') : 'Dictée non supportée ici' }}</q-tooltip>
</q-btn>
</div>
<!-- Typeahead client / adresse : tape → suggestions (nom, adresse, tél., courriel) → insère l'entité dans la demande -->
<q-select v-if="entitySearch" dense outlined use-input hide-dropdown-icon input-debounce="200"
v-model="entityPick" :options="entityOptions" @filter="filterEntities" @update:model-value="onPickEntity"
option-label="label" clearable class="q-mt-sm"
label="Ajouter un client / une adresse — recherche (nom · adresse · téléphone · courriel)">
<template #prepend><q-icon name="person_search" color="deep-purple-5" /></template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar><q-icon :name="scope.opt.icon" size="18px" color="deep-purple-5" /></q-item-section>
<q-item-section><q-item-label>{{ scope.opt.label }}</q-item-label><q-item-label caption>{{ scope.opt.caption }}</q-item-label></q-item-section>
</q-item>
</template>
<template #no-option><q-item><q-item-section class="text-grey-6">Tape au moins 2 lettres…</q-item-section></q-item></template>
</q-select>
<div class="row justify-end q-mt-xs">
<q-btn unelevated color="deep-purple-6" icon="auto_awesome" :label="thread.length ? 'Envoyer' : 'Préparer le plan'" no-caps :disable="!text.trim() || planning" :loading="planning" @click="plan" />
</div>
<!-- Fil de conversation (chat MULTI-TOURS) : l'assistant peut poser une question → réponds dans le champ ci-dessus -->
<div v-if="thread.length" class="orch-thread q-mt-md">
<div v-for="(m, i) in thread" :key="i" :class="['orch-msg', 'orch-msg-' + m.role]">
<q-icon v-if="m.role === 'assistant'" name="bolt" size="14px" color="deep-purple-6" class="q-mr-xs" />{{ m.text }}
</div>
</div>
<!-- Vérifications (lecture immédiate — pas de confirmation) -->
<div v-if="diagnoses.length" class="q-mt-md">
<div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag">
<div class="row items-center q-mb-xs">
<q-icon name="network_check" color="primary" size="18px" class="q-mr-sm" />
<span class="text-weight-medium">Vérification</span>
<span class="text-caption text-grey-6 q-ml-sm">« {{ a.customer_query }} »</span>
</div>
<!-- 1 client → diagnostic -->
<template v-if="a.diagnostic && a.diagnostic.found">
<div class="row items-center no-wrap">
<span class="diag-dot" :style="{ background: dotColor(a.diagnostic.summary) }"></span>
<div class="col">
<div class="text-weight-medium">{{ a.diagnostic.customer.customer_name }}</div>
<div class="text-caption text-grey-7">{{ a.diagnostic.location ? a.diagnostic.location.address : '—' }}</div>
</div>
<div class="text-right">
<div class="text-weight-medium" :style="{ color: dotColor(a.diagnostic.summary) }">{{ a.diagnostic.summary.label }}</div>
<div class="text-caption text-grey-6">{{ a.diagnostic.summary.detail }}<span v-if="a.diagnostic.summary.signal"> · {{ a.diagnostic.summary.signal }}</span></div>
</div>
</div>
<div v-if="a.diagnostic.locations && a.diagnostic.locations.length > 1" class="text-caption text-warning q-mt-xs"><q-icon name="place" size="13px" /> {{ a.diagnostic.locations.length }} adresses — affichage de la principale</div>
</template>
<!-- plusieurs clients → préciser l'adresse -->
<template v-else-if="a.diagnostic && a.diagnostic.ambiguous">
<div class="text-caption text-warning q-mb-xs">Plusieurs clients — précise l'adresse :</div>
<q-list dense bordered class="rounded-borders">
<q-item clickable v-ripple v-for="(m, j) in a.diagnostic.matches" :key="j" @click="pickDiag(a, m)">
<q-item-section><q-item-label>{{ m.customer_name }}</q-item-label><q-item-label caption>{{ m.address || m.phone || m.email }}</q-item-label></q-item-section>
<q-item-section side><q-icon name="chevron_right" color="grey-5" /></q-item-section>
</q-item>
</q-list>
</template>
<div v-else-if="a.diagnostic && !a.diagnostic.found" class="text-caption text-negative">Client introuvable pour « {{ a.diagnostic.query || a.customer_query }} »</div>
<div v-else class="text-caption text-grey-6"><q-spinner size="14px" /> diagnostic…</div>
</div>
</div>
<!-- Plan d'actions à confirmer -->
<div v-if="writeActions.length" class="q-mt-md">
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Plan ({{ writeActions.length }} action{{ writeActions.length > 1 ? 's' : '' }}) — vérifie puis confirme :</div>
<div v-for="(a, i) in writeActions" :key="i" class="orch-act">
<div class="row items-center no-wrap q-mb-xs">
<q-icon :name="iconOf(a.type)" :color="colorOf(a.type)" size="18px" class="q-mr-sm" />
<span class="text-weight-medium">{{ labelOf(a.type) }}</span>
<q-badge v-if="a.type === 'create_ticket' && a.status === 'On Hold'" color="amber-1" text-color="amber-9" label="En attente" class="q-ml-sm" />
<q-badge v-if="a.category" :label="a.category" color="grey-3" text-color="grey-8" class="q-ml-xs" />
<q-space />
<q-btn flat round dense size="xs" icon="delete" color="grey-5" @click="actions.splice(actions.indexOf(a), 1)" />
</div>
<!-- Aperçu générique (outils STAFF dispatch/planification : create_job, assign_tech, create_recurring_shift…) -->
<div v-if="a.preview" class="text-body2 text-grey-8 q-mb-xs">{{ a.preview }}</div>
<q-badge v-if="a.severity === 'high'" color="red-1" text-color="red-9" class="q-mb-xs"><q-icon name="warning" size="13px" class="q-mr-xs" />Action conséquente</q-badge>
<!-- destinataire / client résolu -->
<div v-if="a.candidates && a.candidates.length" class="q-mb-xs">
<q-select dense outlined v-model="a.resolved" :options="a.candidates" option-label="customer_name" map-options emit-value
:display-value="a.resolved ? a.resolved.customer_name : '— aucun —'" label="Client / destinataire" @update:model-value="onResolved(a)">
<template #option="s">
<q-item v-bind="s.itemProps"><q-item-section>
<q-item-label>{{ s.opt.customer_name }}<q-badge v-if="s.opt.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></q-item-label>
<q-item-label caption>{{ s.opt.kind === 'technicien' ? '🔧 technicien · ' : '' }}{{ s.opt.address || s.opt.phone || s.opt.email }}{{ s.opt.can_sms ? ' · SMS ✓' : '' }}</q-item-label>
</q-item-section></q-item>
</template>
</q-select>
<div v-if="a.type === 'send_sms' && a.resolved && !a.resolved.can_sms" class="text-caption text-warning"><q-icon name="warning" size="13px" /> pas de mobile — SMS impossible</div>
</div>
<div v-else-if="a.phone" class="text-caption text-grey-7 q-mb-xs"><q-icon name="call" size="13px" /> {{ a.phone }}</div>
<div v-else-if="a.customer_query || a.to_query" class="text-caption text-warning q-mb-xs"><q-icon name="info" size="13px" /> « {{ a.customer_query || a.to_query }} » — non résolu</div>
<!-- Adresse de service liée (compte multi-adresses → on choisit la bonne, pas la facturation) -->
<div v-if="a.type === 'create_ticket' && a.resolved" class="q-mb-xs">
<q-select v-if="a._locs && a._locs.length > 1" dense outlined v-model="a.service_location" :options="a._locs" option-label="address" option-value="name" emit-value map-options clearable label="Adresse de service">
<template #prepend><q-icon name="place" color="warning" /></template>
</q-select>
<div v-else-if="a.resolved.address || a.service_location" class="text-caption text-grey-7"><q-icon name="place" size="13px" color="warning" /> {{ a.resolved.address || locLabel(a) }}</div>
</div>
<!-- contenu éditable -->
<q-input v-if="a.type === 'create_ticket'" v-model="a.subject" dense outlined label="Sujet" />
<q-input v-if="a.type === 'send_sms'" v-model="a.message" type="textarea" autogrow :rows="2" dense outlined label="Texto" />
<template v-if="a.type === 'send_email'"><q-input v-model="a.subject" dense outlined label="Sujet" class="q-mb-xs" /><q-input v-model="a.body" type="textarea" autogrow :rows="2" dense outlined label="Courriel" /></template>
<q-input v-if="a.type === 'note'" v-model="a.text" type="textarea" autogrow :rows="2" dense outlined label="Note" />
</div>
</div>
<!-- Résultats -->
<div v-if="results.length" class="q-mt-md">
<div v-for="(r, i) in results" :key="i" class="text-body2" :class="r.ok ? 'text-green-8' : 'text-negative'">
<q-icon :name="r.ok ? 'check_circle' : 'error'" size="15px" /> {{ summaryOf(r) }}
</div>
</div>
</q-card-section>
<q-card-actions v-if="writeActions.length && !results.length" align="right">
<q-btn flat label="Annuler" @click="actions = []" />
<q-btn unelevated color="green-7" icon="send" label="Confirmer et exécuter" no-caps :loading="running" @click="run" />
</q-card-actions>
<q-card-actions v-else-if="results.length" align="right">
<q-btn unelevated color="grey-7" label="Fermer" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { useAuthStore } from 'src/stores/auth'
const props = defineProps({
modelValue: Boolean,
// Réutilisable pour DEUX surfaces NL (cf feedback_reuse_shared_components) : l'orchestrateur COMMS
// (défauts /collab/orchestrate — tickets/SMS/courriel) ET le copilote STAFF dispatch/planif
// (plan-endpoint="/staff-agent"). Défauts = comportement comms inchangé.
initialText: { type: String, default: '' }, // pré-remplissage (ex. requête ⌘K) → auto-plan à l'ouverture
title: { type: String, default: 'Commande dictée' },
planEndpoint: { type: String, default: '/collab/orchestrate' },
runEndpoint: { type: String, default: '/collab/orchestrate/run' },
sendIdentity: { type: Boolean, default: false }, // joindre X-Authentik-Email (routes STAFF : agissent au nom de l'agent connecté)
entitySearch: { type: Boolean, default: false }, // typeahead client/adresse → injecte l'entité résolue dans la demande (ex. « Allouer un technicien »)
})
const emit = defineEmits(['update:modelValue', 'done'])
const $q = useQuasar()
const open = ref(false)
const text = ref('')
// Typeahead entité (client/adresse) — recherche floue hub (nom/adresse/téléphone/courriel) ; le choix INJECTE l'entité
// RÉSOLUE (id + nom + adresse) dans la demande → l'assistant Gemini en extrait l'ID pour agir (proposer_rdv_client, etc.).
const entityPick = ref(null)
const entityOptions = ref([])
function filterEntities (val, update) {
const q = (val || '').trim()
if (q.length < 2) { update(() => { entityOptions.value = [] }); return }
update(async () => {
try {
const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`, { headers: reqHeaders() })
const matches = r.ok ? ((await r.json()).matches || []) : []
entityOptions.value = matches.filter(m => m.kind !== 'équipe').slice(0, 8).map(m => ({
id: m.name, name: m.customer_name || m.name, address: m.address || '',
icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person',
label: m.customer_name || m.name,
caption: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name,
}))
} catch { entityOptions.value = [] }
})
}
function onPickEntity (opt) {
if (!opt) return
const frag = `client ${opt.id} (${opt.name}${opt.address ? ' — ' + opt.address : ''})`
const t = text.value.trim()
const sep = !t ? '' : (/[:,-]$/.test(t) ? ' ' : ' · ') // pas de « · » après un « : » ou une virgule
text.value = (t ? t + sep : '') + frag + ' '
nextTick(() => { entityPick.value = null; entityOptions.value = [] })
}
const actions = ref([])
const results = ref([])
const reply = ref('')
const thread = ref([]) // fil de conversation (chat multi-tours) : [{ role:'user'|'assistant', text }]
// En-têtes de requête : identité de l'agent connecté pour les routes STAFF (attribution + suivi + permissions).
function reqHeaders () {
const h = { 'Content-Type': 'application/json' }
if (props.sendIdentity) { const u = useAuthStore().user; if (u && u !== 'authenticated') h['X-Authentik-Email'] = u }
return h
}
const planning = ref(false)
const running = ref(false)
const listening = ref(false)
const micSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition)
let recog = null
// Les vérifications (lecture) sont déjà exécutées dans le plan → séparées des actions à confirmer.
const diagnoses = computed(() => actions.value.filter(a => a.type === 'diagnose'))
const writeActions = computed(() => actions.value.filter(a => a.type !== 'diagnose'))
function dotColor (s) { return (s && s.online === true) ? '#16a34a' : (s && s.online === false) ? '#dc2626' : '#94a3b8' }
async function pickDiag (a, m) {
a.diagnostic = null
try { const r = await fetch(`${HUB_URL}/collab/service-status?customer=${encodeURIComponent(m.name)}`); if (r.ok) a.diagnostic = await r.json() } catch (e) { a.diagnostic = { ok: false, error: e.message } }
}
watch(() => props.modelValue, v => { open.value = v; if (v) { reset(); if (props.initialText) { text.value = props.initialText; nextTick(() => plan()) } } })
watch(open, v => emit('update:modelValue', v))
function reset () { text.value = ''; actions.value = []; results.value = []; reply.value = ''; thread.value = []; if (recog) { try { recog.stop() } catch (e) {} } listening.value = false }
const iconOf = t => ({ create_ticket: 'confirmation_number', send_sms: 'sms', send_email: 'mail', note: 'sticky_note_2', create_job: 'add_task', assign_tech: 'engineering', add_assistant: 'group_add', set_job_status: 'flag', reschedule_job: 'event_repeat', create_recurring_shift: 'event_available', follow_doc: 'notifications_active' }[t] || 'bolt')
const colorOf = t => ({ create_ticket: 'teal-7', send_sms: 'green-6', send_email: 'red-5', note: 'amber-7', create_job: 'indigo-6', assign_tech: 'indigo-6', add_assistant: 'blue-grey-6', set_job_status: 'deep-orange-6', reschedule_job: 'deep-purple-5', create_recurring_shift: 'teal-6', follow_doc: 'blue-6' }[t] || 'grey-7')
const labelOf = t => ({ create_ticket: 'Créer un ticket', send_sms: 'Envoyer un texto', send_email: 'Envoyer un courriel', note: 'Note interne', create_job: 'Créer un job', assign_tech: 'Assigner un technicien', add_assistant: 'Ajouter un assistant', set_job_status: 'Changer le statut', reschedule_job: 'Reporter le rendez-vous', create_recurring_shift: 'Horaire récurrent', follow_doc: 'Suivre' }[t] || t)
function summaryOf (r) {
if (r.type === 'create_ticket') return r.ok ? `Ticket ${r.name} créé (${r.label || ''})` : 'Ticket : ' + (r.error || 'échec')
if (r.type === 'send_sms') return r.ok ? `Texto envoyé à ${r.to} (${r.via})` : 'Texto : ' + (r.error || 'échec')
if (r.type === 'send_email') return r.ok ? `Courriel envoyé à ${r.to}` : 'Courriel : ' + (r.error || 'échec')
if (r.type === 'note') return 'Note : ' + (r.text || '')
// Actions STAFF dispatch/planif (et repli générique) : l'exécuteur renvoie { message } / { error }.
return (labelOf(r.type) || 'Action') + ' : ' + (r.message || r.error || (r.ok ? 'fait' : 'échec'))
}
function toggleMic () {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
if (!SR) { $q.notify({ type: 'warning', message: 'Dictée non supportée par ce navigateur' }); return }
if (listening.value) { try { recog && recog.stop() } catch (e) {} return }
recog = new SR(); recog.lang = 'fr-CA'; recog.interimResults = true; recog.continuous = false
recog.onresult = e => { let t = ''; for (const r of e.results) t += r[0].transcript; text.value = t }
recog.onend = () => { listening.value = false }
recog.onerror = () => { listening.value = false }
try { recog.start(); listening.value = true } catch (e) { listening.value = false }
}
async function plan () {
const userText = text.value.trim()
if (!userText || planning.value) return
planning.value = true; results.value = []
thread.value.push({ role: 'user', text: userText })
const history = thread.value.slice(0, -1).map(m => ({ role: m.role, content: m.text })) // tours précédents = contexte
text.value = '' // vide le champ → prêt à RÉPONDRE à une question de l'assistant (chat)
try {
const r = await fetch(`${HUB_URL}${props.planEndpoint}`, { method: 'POST', headers: reqHeaders(), body: JSON.stringify({ text: userText, history }) })
const d = await r.json()
if (d.ok) {
actions.value = d.actions || [] // plan du DERNIER tour (remplace le précédent)
reply.value = d.reply || ''
if (d.reply) thread.value.push({ role: 'assistant', text: d.reply }) // réponse/question de l'assistant → dans le fil
for (const a of actions.value) if (a.type === 'create_ticket' && a.resolved) onResolved(a) // pré-charge les adresses de service
if (!actions.value.length && !d.reply) $q.notify({ type: 'info', message: 'Aucune action détectée — reformule.' })
} else { thread.value.push({ role: 'assistant', text: '⚠ ' + (d.error || 'Échec') }); $q.notify({ type: 'negative', message: d.error || 'Échec' }) }
} catch (e) { thread.value.push({ role: 'assistant', text: '⚠ ' + e.message }); $q.notify({ type: 'negative', message: e.message }) } finally { planning.value = false }
}
// Quand le client résolu change (ou au chargement) : récupère ses adresses de service et présélectionne celle qui matche.
async function onResolved (a) {
if (a.type !== 'create_ticket' || !a.resolved || !a.resolved.name) { a._locs = []; return }
a.service_location = a.resolved.service_location || ''
try {
const r = await fetch(`${HUB_URL}/collab/service-locations?customer=${encodeURIComponent(a.resolved.name)}`)
if (r.ok) { const d = await r.json(); a._locs = d.locations || []; if (!a.service_location && a._locs.length) a.service_location = a._locs[0].name } // adresse de service active la plus récente (triée 1re)
} catch (e) { a._locs = [] }
}
function locLabel (a) { const l = (a._locs || []).find(x => x.name === a.service_location); return l ? l.address : a.service_location }
async function run () {
running.value = true
try {
const r = await fetch(`${HUB_URL}${props.runEndpoint}`, { method: 'POST', headers: reqHeaders(), body: JSON.stringify({ actions: writeActions.value }) })
const d = await r.json()
results.value = d.results || []
const ok = results.value.filter(x => x.ok).length
$q.notify({ type: ok ? 'positive' : 'negative', message: `${ok}/${results.value.length} action(s) exécutée(s)` })
emit('done')
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { running.value = false }
}
</script>
<style scoped>
.orch-act { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #faf9ff; }
.orch-diag { border: 1px solid #e0e7ff; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #f5f7ff; }
.diag-dot { width: 11px; height: 11px; border-radius: 50%; margin-right: 10px; flex: 0 0 auto; }
.orch-reply { border-left: 3px solid #7c6cf6; background: #f6f5ff; padding: 8px 12px; border-radius: 0 8px 8px 0; color: #4a4a5a; font-size: 0.92rem; white-space: pre-wrap; }
.orch-thread { display: flex; flex-direction: column; gap: 8px; max-height: 42vh; overflow-y: auto; }
.orch-msg { padding: 8px 12px; border-radius: 10px; font-size: 0.92rem; white-space: pre-wrap; max-width: 88%; line-height: 1.4; }
.orch-msg-user { align-self: flex-end; background: #ede9fe; color: #3a3a4a; border-bottom-right-radius: 3px; }
.orch-msg-assistant { align-self: flex-start; background: #f6f5ff; border-left: 3px solid #7c6cf6; color: #4a4a5a; border-bottom-left-radius: 3px; }
</style>