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:
parent
54fdc22c97
commit
0372176fc8
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user