-
-
-
-
-
- {{ scope.opt.label }}{{ scope.opt.caption }}
-
-
- Tape au moins 2 lettres…
-
@@ -166,40 +163,19 @@ const props = defineProps({
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 = [] })
+// Options cliquables (désambiguïsation) proposées par l'assistant DANS le fil : « quel client / entité ? ». Un clic
+// envoie le choix comme tour suivant → l'assistant continue avec l'id retenu. Remplace l'ancien 2e champ de recherche.
+const options = ref([])
+function pickOption (o) {
+ options.value = []
+ text.value = `${o.label} (${o.id})`
+ plan()
}
const actions = ref([])
const results = ref([])
@@ -230,9 +206,9 @@ 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(() => thread.value.length + actions.value.length + results.value.length, scrollDown) // défile vers le bas à chaque nouveau contenu (comme un chat)
+watch(() => thread.value.length + actions.value.length + results.value.length + options.value.length, scrollDown) // défile vers le bas à chaque nouveau contenu (comme un chat)
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 }
+function reset () { text.value = ''; actions.value = []; results.value = []; reply.value = ''; thread.value = []; options.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')
@@ -260,7 +236,7 @@ function toggleMic () {
async function plan () {
const userText = text.value.trim()
if (!userText || planning.value) return
- planning.value = true; results.value = []
+ planning.value = true; results.value = []; options.value = [] // nouveau tour → efface les options du tour précédent
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)
@@ -269,6 +245,7 @@ async function plan () {
const d = await r.json()
if (d.ok) {
actions.value = d.actions || [] // plan du DERNIER tour (remplace le précédent)
+ options.value = d.options || [] // candidats cliquables (désambiguïsation client/entité) proposés par l'assistant
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
@@ -313,4 +290,8 @@ async function run () {
.orch-msg { padding: 8px 12px; border-radius: 12px; font-size: 0.92rem; white-space: pre-wrap; max-width: 86%; line-height: 1.45; box-shadow: 0 1px 1px rgba(24,32,46,.05); }
.orch-msg-user { align-self: flex-end; background: #7c6cf6; color: #fff; border-bottom-right-radius: 3px; }
.orch-msg-assistant { align-self: flex-start; background: #fff; border: 1px solid #ece9fb; color: #3a3a4a; border-bottom-left-radius: 3px; }
+/* Options cliquables (désambiguïsation) — menu sous la question de l'assistant, côté gauche comme une bulle assistant */
+.orch-options { align-self: flex-start; max-width: 92%; background: #fff; border: 1px solid #ece9fb; border-radius: 12px; overflow: hidden; padding: 2px 0; box-shadow: 0 1px 1px rgba(24,32,46,.05); }
+.orch-option { min-height: 44px; }
+.orch-option + .orch-option { border-top: 1px solid #f2f0fb; }
diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue
index b8d2f30..9e38363 100644
--- a/apps/ops/src/layouts/MainLayout.vue
+++ b/apps/ops/src/layouts/MainLayout.vue
@@ -170,7 +170,7 @@
+ title="Assistant Ops" plan-endpoint="/staff-agent" run-endpoint="/staff-agent/run" send-identity />
diff --git a/services/targo-hub/lib/agent-tools.json b/services/targo-hub/lib/agent-tools.json
index c318c1a..86acfcf 100644
--- a/services/targo-hub/lib/agent-tools.json
+++ b/services/targo-hub/lib/agent-tools.json
@@ -20,6 +20,7 @@
{ "audience": "staff", "mode": "read", "title": "Trouver un créneau", "type": "function", "function": { "name": "find_slot", "description": "STAFF/dispatch. Propose les meilleurs créneaux disponibles (technicien + date + heure) selon la durée et la compétence requise. LECTURE — ne réserve rien ; présente les options à l'utilisateur.", "parameters": { "type": "object", "properties": { "duration_h": { "type": "number", "description": "Durée en heures (défaut 1)" }, "skill": { "type": "string", "description": "Compétence requise (optionnel)" }, "after_date": { "type": "string", "description": "Chercher à partir de cette date AAAA-MM-JJ (défaut aujourd'hui)" } }, "required": [] } } },
{ "audience": "staff", "mode": "read", "title": "Ressources disponibles", "type": "function", "function": { "name": "resource_availability", "description": "STAFF/dispatch. DIAGNOSTIC : combien de techniciens QUALIFIÉS pour une compétence sont DISPONIBLES sur une période, et combien d'heures libres. LECTURE. À appeler après resolve_skill pour répondre « a-t-on la ressource ? » avant de proposer ; find_slot donnera ensuite les créneaux précis.", "parameters": { "type": "object", "properties": { "skill": { "type": "string", "description": "Compétence requise (issue de resolve_skill), ex. réparation, installation" }, "after_date": { "type": "string", "description": "Dès cette date AAAA-MM-JJ (défaut aujourd'hui)" }, "days": { "type": "number", "description": "Fenêtre en jours (défaut 7)" } }, "required": [] } } },
{ "audience": "staff", "mode": "read", "title": "État du service (modem/signal)", "type": "function", "function": { "name": "check_service", "description": "STAFF/dispatch. DIAGNOSTIC À DISTANCE : état LIVE du service d'un client — modem/ONU en ligne ou hors ligne, puissance du signal (Rx dBm), abonnement. LECTURE. À utiliser AVANT de proposer un déplacement pour un problème de connexion : si l'équipement est EN LIGNE, propose d'abord un correctif à distance (redémarrage) ; s'il est HORS LIGNE avec signal faible/nul, un déplacement (réparation) est justifié. Fournis customer (id C-xxxx) ou query (nom/adresse/téléphone/courriel).", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client (C-xxxx) si connu" }, "query": { "type": "string", "description": "Sinon : nom / adresse / téléphone / courriel du client" } }, "required": [] } } },
+ { "audience": "staff", "mode": "read", "title": "Trouver un client", "type": "function", "function": { "name": "find_customer", "description": "STAFF. Recherche un client par nom / adresse / téléphone / courriel et renvoie les CANDIDATS probables. LECTURE. À appeler dès qu'un client n'est PAS déjà identifié par son id (C-xxxx) : UN seul candidat → prends son id et continue ; PLUSIEURS → NE DEVINE PAS, demande brièvement « lequel ? » — l'interface affiche la liste cliquable des candidats, ne les réénumère donc pas tous en texte. Enchaîne l'action avec l'id choisi.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Nom, adresse, téléphone ou courriel (même partiel)" } }, "required": ["query"] } } },
{ "audience": "staff", "mode": "write", "permission": "create_jobs", "title": "Créer un job", "type": "function", "function": { "name": "create_job", "description": "STAFF/dispatch. Crée un Dispatch Job (intervention). ÉCRITURE — proposée puis confirmée par l'utilisateur avant exécution. auto_assign=true assigne au meilleur tech disponible.", "parameters": { "type": "object", "properties": { "subject": { "type": "string", "description": "Description du travail" }, "customer_id": { "type": "string", "description": "ID client (optionnel)" }, "service_location": { "type": "string", "description": "Nom du lieu de service (optionnel)" }, "address": { "type": "string", "description": "Adresse civique de l'intervention (texte libre, ex. « 2338 rue Ste-Clotilde ») — si aucun lieu de service lié" }, "priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "Priorité (défaut high pour urgence, medium sinon)" }, "job_type": { "type": "string", "enum": ["Installation", "Réparation", "Maintenance", "Retrait", "Dépannage", "Autre"], "description": "Type de travail (défaut Dépannage)" }, "notes": { "type": "string", "description": "Contexte additionnel" }, "auto_assign": { "type": "boolean", "description": "Assigner automatiquement au meilleur tech dispo (défaut true)" } }, "required": ["subject"] } } },
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Assigner un technicien", "type": "function", "function": { "name": "assign_tech", "description": "STAFF/dispatch. Assigne OU réassigne un Dispatch Job à un technicien (pose aussi l'heure au premier trou libre du quart). ÉCRITURE — confirmée avant exécution. Résous d'abord le tech via list_technicians pour obtenir tech_id.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "tech_id": { "type": "string", "description": "id du technicien (TECH-xxxx), via list_technicians" }, "date": { "type": "string", "description": "Date AAAA-MM-JJ (optionnel)" }, "start": { "type": "string", "description": "Heure HH:MM (optionnel)" } }, "required": ["job", "tech_id"] } } },
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Ajouter un assistant", "type": "function", "function": { "name": "add_assistant", "description": "STAFF/dispatch. Ajoute un technicien en renfort (assistant) sur un job — le tech assigné (lead) reste inchangé. ÉCRITURE — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "tech_id": { "type": "string", "description": "id du technicien assistant (TECH-xxxx)" }, "tech_name": { "type": "string", "description": "Nom du technicien (optionnel, pour l'affichage)" } }, "required": ["job", "tech_id"] } } },
diff --git a/services/targo-hub/lib/staff-agent.js b/services/targo-hub/lib/staff-agent.js
index 50b7e53..73b3657 100644
--- a/services/targo-hub/lib/staff-agent.js
+++ b/services/targo-hub/lib/staff-agent.js
@@ -130,7 +130,10 @@ async function read_check_service ({ customer, query } = {}) {
try {
const r = await require('./ticket-collab').serviceStatus({ customer: customer || '', q: query || '' })
if (!r || r.ok === false) return { error: (r && r.error) || 'statut de service indisponible' }
- if (r.found === false) return { found: false, ambiguous: !!r.ambiguous, matches: (r.matches || []).slice(0, 6).map(m => ({ id: m.name, name: m.customer_name || m.name })), note: r.ambiguous ? 'Plusieurs clients correspondent — précise lequel (customer=id).' : 'Client/service introuvable.' }
+ if (r.found === false) {
+ const cands = (r.matches || []).slice(0, 6).map(m => ({ id: m.name, label: m.customer_name || m.name, caption: m.address || m.phone || m.email || '', icon: 'person' }))
+ return { found: false, ambiguous: !!r.ambiguous, candidates: cands, note: r.ambiguous ? "Plusieurs clients correspondent — présente-les (l'interface affiche des boutons cliquables) et demande lequel ; ne devine pas." : 'Client/service introuvable.' }
+ }
const s = r.summary || {}
let advice
if (s.online === true) advice = 'Équipement EN LIGNE' + (s.rxPower != null ? ' (Rx ' + s.rxPower + ' dBm)' : '') + " → tente d'abord un correctif À DISTANCE (redémarrage / reprovision) ; un déplacement n'est probablement PAS nécessaire."
@@ -139,7 +142,25 @@ async function read_check_service ({ customer, query } = {}) {
return { found: true, customer: r.customer && r.customer.name, customer_name: r.customer && r.customer.customer_name, online: s.online, rx_power_dbm: s.rxPower != null ? s.rxPower : null, signal: s.signal || null, status_label: s.label || '', source: s.source || '', subscriptions: (r.subscriptions || []).map(x => (x.plan || '?') + ' · ' + (x.status || '?')), advice }
} catch (e) { return { error: e.message } }
}
-const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot, resolve_skill: read_resolve_skill, resource_availability: read_resource_availability, check_service: read_check_service }
+// Recherche floue d'un client → CANDIDATS (l'UI les rend cliquables dans le fil). Réutilise /collab/customer-search
+// (même endpoint que l'ancien typeahead) MAIS c'est l'ASSISTANT qui liste les options — plus besoin d'un 2e champ.
+async function read_find_customer ({ query } = {}) {
+ const q = String(query || '').trim()
+ if (q.length < 2) return { error: 'requête trop courte (2 lettres min)' }
+ try {
+ const r = await hubCall('GET', `/collab/customer-search?q=${encodeURIComponent(q)}&team=1`)
+ const matches = (r.data && r.data.matches) || []
+ const candidates = matches.filter(m => m.kind !== 'équipe').slice(0, 8).map(m => ({
+ id: m.name, label: m.customer_name || m.name,
+ caption: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name,
+ icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person',
+ }))
+ if (!candidates.length) return { count: 0, candidates: [], note: `Aucun client pour « ${q} ».` }
+ if (candidates.length === 1) return { count: 1, candidates, note: `Un seul client : ${candidates[0].label} (${candidates[0].id}) — utilise cet id et continue.` }
+ return { count: candidates.length, candidates, note: `${candidates.length} clients possibles — demande « lequel ? » ; l'interface affiche la liste cliquable (ne les réénumère pas en texte).` }
+ } catch (e) { return { error: e.message } }
+}
+const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot, resolve_skill: read_resolve_skill, resource_availability: read_resource_availability, check_service: read_check_service, find_customer: read_find_customer }
// ── Jours de la semaine — clés du weekly_schedule (= DOW_KEYS de roster.js) ──
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
@@ -342,7 +363,8 @@ function systemPrompt (email) {
Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? ` (${email})` : ''}.
Tu transformes une commande en langage naturel en APPELS D'OUTILS.
RÈGLES :
-- Utilise d'abord les outils de LECTURE (list_technicians, get_job, resolve_skill, resource_availability, find_slot, check_service) pour RÉSOUDRE et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir.
+- Utilise d'abord les outils de LECTURE (find_customer, list_technicians, get_job, resolve_skill, resource_availability, find_slot, check_service) pour RÉSOUDRE et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir.
+- IDENTIFIER LE CLIENT : si le client n'est pas donné par son id (C-xxxx), appelle find_customer avec ce que tu as (nom, adresse, téléphone, courriel). UN seul candidat → prends son id et continue. PLUSIEURS → NE DEVINE PAS : demande brièvement « lequel ? » ; l'interface affiche la liste cliquable des candidats, donc NE les réénumère PAS tous en texte. Utilise ensuite l'id choisi pour l'action.
- PROBLÈME DE CONNEXION (« pas d'internet », « lent », « signal », « hors ligne », « coupé ») : appelle check_service AVANT de proposer un déplacement. Si le modem est EN LIGNE → propose d'abord un correctif À DISTANCE via device_action(reboot) ; s'il est HORS LIGNE avec signal faible/nul → un déplacement (réparation) est justifié. Dis à l'utilisateur ce que tu as constaté (en ligne/hors ligne · Rx dBm) avant de recommander.
- CORRECTIF À DISTANCE (Do Stuff) : device_action agit sur l'ONU du client via l'OLT — reboot (redémarrer), speed (changer le forfait), suspend/unsuspend (couper/rétablir l'internet), activate/replace/remove (provisionnement). Fournis serial OU customer_id (on résout l'ONU). Utilise-le pour éviter un déplacement quand c'est réparable à distance. ÉCRITURE RÉSEAU conséquente → toujours PROPOSÉE puis CONFIRMÉE ; n'affirme jamais que c'est fait avant confirmation.
- INTERVENTION à créer/assigner : procède par ÉTAPES conversationnelles, ne saute PAS à la création. (1) Cerne la RAISON (si vague ou absente, pose UNE question courte plutôt que deviner). (2) resolve_skill → compétence requise. (3) resource_availability (combien de techs qualifiés dispo + heures libres) puis find_slot (créneaux précis) → DISPONIBILITÉ réelle. (4) SEULEMENT ensuite propose create_job / assign_tech / proposer_rdv_client. Résume ton diagnostic (raison → compétence → ressource dispo) AVANT de proposer, et inclus l'adresse dans create_job si fournie.
@@ -357,7 +379,12 @@ async function execToolPlan (name, args, ctx) {
const meta = META[name]
if (!meta) return { error: 'outil inconnu : ' + name }
try {
- if (meta.mode === 'read') { const fn = READERS[name]; return fn ? await fn(args || {}) : { error: 'lecteur manquant : ' + name } }
+ if (meta.mode === 'read') {
+ const fn = READERS[name]; if (!fn) return { error: 'lecteur manquant : ' + name }
+ const out = await fn(args || {})
+ if (out && Array.isArray(out.candidates) && out.candidates.length > 1) ctx.options = out.candidates // client ambigu → l'UI affiche des options cliquables
+ return out
+ }
const w = WRITES[name]
if (!w) return { error: 'exécuteur manquant : ' + name }
const staged = await w.build(args || {}, ctx.email)
@@ -371,7 +398,7 @@ async function execToolPlan (name, args, ctx) {
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 ctx = { email, planned, options: [] } // options = candidats cliquables (désambiguïsation client/entité) à afficher dans le fil
// 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) })) : []
@@ -394,7 +421,7 @@ async function plan (text, email, history) {
try { response = await geminiChat(cur) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
}
const reply = response.choices?.[0]?.message?.content || (planned.length ? 'Plan préparé — confirme pour exécuter.' : 'Aucune action détectée — reformule.')
- return { ok: true, reply, actions: planned, transcript: String(text || '') }
+ return { ok: true, reply, actions: planned, options: ctx.options, transcript: String(text || '') }
}
// RUN : exécute les actions CONFIRMÉES. type → exécuteur (liste blanche) + re-vérif permission + identité.