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 }}
+
+
@@ -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') {