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>
This commit is contained in:
louispaulb 2026-07-19 21:07:55 -04:00
parent 54fdc22c97
commit 0372176fc8
2 changed files with 31 additions and 14 deletions

View File

@ -34,11 +34,15 @@
</q-select> </q-select>
<div class="row justify-end q-mt-xs"> <div class="row justify-end q-mt-xs">
<q-btn unelevated color="deep-purple-6" icon="auto_awesome" label="Préparer le plan" no-caps :disable="!text.trim() || planning" :loading="planning" @click="plan" /> <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> </div>
<!-- Réponse de l'assistant (résumé NL / résultat des lectures, ex. « Pour quel technicien ? ») --> <!-- Fil de conversation (chat MULTI-TOURS) : l'assistant peut poser une question réponds dans le champ ci-dessus -->
<div v-if="reply" class="orch-reply q-mt-md">{{ reply }}</div> <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) --> <!-- Vérifications (lecture immédiate pas de confirmation) -->
<div v-if="diagnoses.length" class="q-mt-md"> <div v-if="diagnoses.length" class="q-mt-md">
@ -196,6 +200,7 @@ function onPickEntity (opt) {
const actions = ref([]) const actions = ref([])
const results = ref([]) const results = ref([])
const reply = 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). // En-têtes de requête : identité de l'agent connecté pour les routes STAFF (attribution + suivi + permissions).
function reqHeaders () { function reqHeaders () {
@ -220,7 +225,7 @@ async function pickDiag (a, m) {
watch(() => props.modelValue, v => { open.value = v; if (v) { reset(); if (props.initialText) { text.value = props.initialText; nextTick(() => plan()) } } }) 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)) watch(open, v => emit('update:modelValue', v))
function reset () { text.value = ''; actions.value = []; results.value = []; reply.value = ''; if (recog) { try { recog.stop() } catch (e) {} } listening.value = false } 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 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 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')
@ -246,18 +251,23 @@ function toggleMic () {
} }
async function plan () { async function plan () {
if (!text.value.trim()) return const userText = text.value.trim()
if (!userText || planning.value) return
planning.value = true; results.value = [] 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 { try {
const r = await fetch(`${HUB_URL}${props.planEndpoint}`, { method: 'POST', headers: reqHeaders(), body: JSON.stringify({ text: text.value.trim() }) }) const r = await fetch(`${HUB_URL}${props.planEndpoint}`, { method: 'POST', headers: reqHeaders(), body: JSON.stringify({ text: userText, history }) })
const d = await r.json() const d = await r.json()
if (d.ok) { if (d.ok) {
actions.value = d.actions || [] actions.value = d.actions || [] // plan du DERNIER tour (remplace le précédent)
reply.value = d.reply || '' // le copilote STAFF renvoie un résumé NL / une question ; l'orchestrateur comms non 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 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 && !reply.value) $q.notify({ type: 'info', message: 'Aucune action détectée — reformule.' }) if (!actions.value.length && !d.reply) $q.notify({ type: 'info', message: 'Aucune action détectée — reformule.' })
} else $q.notify({ type: 'negative', message: d.error || 'Échec' }) } else { thread.value.push({ role: 'assistant', text: '⚠ ' + (d.error || 'Échec') }); $q.notify({ type: 'negative', message: d.error || 'Échec' }) }
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { planning.value = false } } 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. // 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) { async function onResolved (a) {
@ -287,4 +297,8 @@ async function run () {
.orch-diag { border: 1px solid #e0e7ff; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #f5f7ff; } .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; } .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-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> </style>

View File

@ -290,11 +290,14 @@ async function execToolPlan (name, args, ctx) {
} }
// PLAN : boucle function-calling. Les LECTURES s'exécutent (résolution), les ÉCRITURES sont mises en attente. // PLAN : boucle function-calling. Les LECTURES s'exécutent (résolution), les ÉCRITURES sont mises en attente.
async function plan (text, email) { async function plan (text, email, history) {
if (!cfg.AI_API_KEY) return { ok: false, error: "Le service IA n'est pas configuré (AI_API_KEY manquante)." } if (!cfg.AI_API_KEY) return { ok: false, error: "Le service IA n'est pas configuré (AI_API_KEY manquante)." }
const planned = [] const planned = []
const ctx = { email, planned } const ctx = { email, planned }
const messages = [{ role: 'system', content: systemPrompt(email) }, { role: 'user', content: String(text || '') }] // Chat MULTI-TOURS : les tours précédents (user/assistant) donnent le contexte pour répondre à une question de
// clarification (« oui, crée l'intervention »). Bornage : 10 derniers tours, 4000 car. chacun.
const hist = Array.isArray(history) ? history.filter(m => m && (m.role === 'user' || m.role === 'assistant') && m.content).slice(-10).map(m => ({ role: m.role, content: String(m.content).slice(0, 4000) })) : []
const messages = [{ role: 'system', content: systemPrompt(email) }, ...hist, { role: 'user', content: String(text || '') }]
let response let response
try { response = await geminiChat(messages) } catch (e) { return { ok: false, error: 'IA : ' + e.message } } try { response = await geminiChat(messages) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
const cur = [...messages] const cur = [...messages]
@ -344,7 +347,7 @@ async function handle (req, res, method, path) {
const email = String(req.headers['x-authentik-email'] || '').toLowerCase() const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
if (path === '/staff-agent' && method === 'POST') { if (path === '/staff-agent' && method === 'POST') {
const b = await parseBody(req) const b = await parseBody(req)
try { return json(res, 200, await plan(b.text || '', email)) } try { return json(res, 200, await plan(b.text || '', email, b.history)) }
catch (e) { return json(res, 200, { ok: false, error: 'Erreur copilote : ' + (e.message || e) }) } catch (e) { return json(res, 200, { ok: false, error: 'Erreur copilote : ' + (e.message || e) }) }
} }
if (path === '/staff-agent/run' && method === 'POST') { if (path === '/staff-agent/run' && method === 'POST') {