From 0372176fc8fd47399b3ae38dbbc1e738c9d6aab0 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sun, 19 Jul 2026 21:07:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20vrai=20CHAT=20multi-tours=20?= =?UTF-8?q?=E2=80=94=20on=20peut=20r=C3=A9pondre=20aux=20questions=20de=20?= =?UTF-8?q?l'assistant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/shared/OrchestratorDialog.vue | 36 +++++++++++++------ services/targo-hub/lib/staff-agent.js | 9 +++-- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/apps/ops/src/components/shared/OrchestratorDialog.vue b/apps/ops/src/components/shared/OrchestratorDialog.vue index b605a31..d898c61 100644 --- a/apps/ops/src/components/shared/OrchestratorDialog.vue +++ b/apps/ops/src/components/shared/OrchestratorDialog.vue @@ -34,11 +34,15 @@
- +
- -
{{ reply }}
+ +
+
+ {{ m.text }} +
+
@@ -196,6 +200,7 @@ function onPickEntity (opt) { 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 () { @@ -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(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 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 () { - if (!text.value.trim()) return + 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: 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() if (d.ok) { - actions.value = d.actions || [] - reply.value = d.reply || '' // le copilote STAFF renvoie un résumé NL / une question ; l'orchestrateur comms non + 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 && !reply.value) $q.notify({ type: 'info', message: 'Aucune action détectée — reformule.' }) - } else $q.notify({ type: 'negative', message: d.error || 'Échec' }) - } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { planning.value = false } + 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) { @@ -287,4 +297,8 @@ async function run () { .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; } diff --git a/services/targo-hub/lib/staff-agent.js b/services/targo-hub/lib/staff-agent.js index c2360e8..7073a4f 100644 --- a/services/targo-hub/lib/staff-agent.js +++ b/services/targo-hub/lib/staff-agent.js @@ -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. -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)." } const 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 try { response = await geminiChat(messages) } catch (e) { return { ok: false, error: 'IA : ' + e.message } } const cur = [...messages] @@ -344,7 +347,7 @@ async function handle (req, res, method, path) { const email = String(req.headers['x-authentik-email'] || '').toLowerCase() if (path === '/staff-agent' && method === 'POST') { 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) }) } } if (path === '/staff-agent/run' && method === 'POST') {