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>
305 lines
22 KiB
Vue
305 lines
22 KiB
Vue
<template>
|
||
<q-dialog v-model="open" @hide="reset">
|
||
<q-card style="min-width:540px;max-width:96vw">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<q-icon name="bolt" color="deep-purple-6" size="22px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold">{{ title }}</div>
|
||
<q-space />
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
|
||
<q-card-section>
|
||
<!-- Dictée / texte -->
|
||
<div class="row items-end q-gutter-sm">
|
||
<q-input v-model="text" type="textarea" autogrow :rows="2" outlined class="col" autofocus
|
||
label="Dicte ou écris (ex. « Crée un ticket de facture non payée pour Louis-Paul, texto au client qu'on attend le paiement »)"
|
||
@keyup.enter.exact.prevent="plan" />
|
||
<q-btn round :color="listening ? 'red-6' : 'deep-purple-5'" :icon="listening ? 'mic' : 'mic_none'" :loading="planning" @click="toggleMic">
|
||
<q-tooltip>{{ micSupported ? (listening ? 'Arrêter la dictée' : 'Dicter à voix') : 'Dictée non supportée ici' }}</q-tooltip>
|
||
</q-btn>
|
||
</div>
|
||
<!-- Typeahead client / adresse : tape → suggestions (nom, adresse, tél., courriel) → insère l'entité dans la demande -->
|
||
<q-select v-if="entitySearch" dense outlined use-input hide-dropdown-icon input-debounce="200"
|
||
v-model="entityPick" :options="entityOptions" @filter="filterEntities" @update:model-value="onPickEntity"
|
||
option-label="label" clearable class="q-mt-sm"
|
||
label="Ajouter un client / une adresse — recherche (nom · adresse · téléphone · courriel)">
|
||
<template #prepend><q-icon name="person_search" color="deep-purple-5" /></template>
|
||
<template #option="scope">
|
||
<q-item v-bind="scope.itemProps">
|
||
<q-item-section avatar><q-icon :name="scope.opt.icon" size="18px" color="deep-purple-5" /></q-item-section>
|
||
<q-item-section><q-item-label>{{ scope.opt.label }}</q-item-label><q-item-label caption>{{ scope.opt.caption }}</q-item-label></q-item-section>
|
||
</q-item>
|
||
</template>
|
||
<template #no-option><q-item><q-item-section class="text-grey-6">Tape au moins 2 lettres…</q-item-section></q-item></template>
|
||
</q-select>
|
||
|
||
<div class="row justify-end q-mt-xs">
|
||
<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>
|
||
|
||
<!-- Fil de conversation (chat MULTI-TOURS) : l'assistant peut poser une question → réponds dans le champ ci-dessus -->
|
||
<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) -->
|
||
<div v-if="diagnoses.length" class="q-mt-md">
|
||
<div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag">
|
||
<div class="row items-center q-mb-xs">
|
||
<q-icon name="network_check" color="primary" size="18px" class="q-mr-sm" />
|
||
<span class="text-weight-medium">Vérification</span>
|
||
<span class="text-caption text-grey-6 q-ml-sm">« {{ a.customer_query }} »</span>
|
||
</div>
|
||
<!-- 1 client → diagnostic -->
|
||
<template v-if="a.diagnostic && a.diagnostic.found">
|
||
<div class="row items-center no-wrap">
|
||
<span class="diag-dot" :style="{ background: dotColor(a.diagnostic.summary) }"></span>
|
||
<div class="col">
|
||
<div class="text-weight-medium">{{ a.diagnostic.customer.customer_name }}</div>
|
||
<div class="text-caption text-grey-7">{{ a.diagnostic.location ? a.diagnostic.location.address : '—' }}</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="text-weight-medium" :style="{ color: dotColor(a.diagnostic.summary) }">{{ a.diagnostic.summary.label }}</div>
|
||
<div class="text-caption text-grey-6">{{ a.diagnostic.summary.detail }}<span v-if="a.diagnostic.summary.signal"> · {{ a.diagnostic.summary.signal }}</span></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="a.diagnostic.locations && a.diagnostic.locations.length > 1" class="text-caption text-warning q-mt-xs"><q-icon name="place" size="13px" /> {{ a.diagnostic.locations.length }} adresses — affichage de la principale</div>
|
||
</template>
|
||
<!-- plusieurs clients → préciser l'adresse -->
|
||
<template v-else-if="a.diagnostic && a.diagnostic.ambiguous">
|
||
<div class="text-caption text-warning q-mb-xs">Plusieurs clients — précise l'adresse :</div>
|
||
<q-list dense bordered class="rounded-borders">
|
||
<q-item clickable v-ripple v-for="(m, j) in a.diagnostic.matches" :key="j" @click="pickDiag(a, m)">
|
||
<q-item-section><q-item-label>{{ m.customer_name }}</q-item-label><q-item-label caption>{{ m.address || m.phone || m.email }}</q-item-label></q-item-section>
|
||
<q-item-section side><q-icon name="chevron_right" color="grey-5" /></q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
</template>
|
||
<div v-else-if="a.diagnostic && !a.diagnostic.found" class="text-caption text-negative">Client introuvable pour « {{ a.diagnostic.query || a.customer_query }} »</div>
|
||
<div v-else class="text-caption text-grey-6"><q-spinner size="14px" /> diagnostic…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Plan d'actions à confirmer -->
|
||
<div v-if="writeActions.length" class="q-mt-md">
|
||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Plan ({{ writeActions.length }} action{{ writeActions.length > 1 ? 's' : '' }}) — vérifie puis confirme :</div>
|
||
<div v-for="(a, i) in writeActions" :key="i" class="orch-act">
|
||
<div class="row items-center no-wrap q-mb-xs">
|
||
<q-icon :name="iconOf(a.type)" :color="colorOf(a.type)" size="18px" class="q-mr-sm" />
|
||
<span class="text-weight-medium">{{ labelOf(a.type) }}</span>
|
||
<q-badge v-if="a.type === 'create_ticket' && a.status === 'On Hold'" color="amber-1" text-color="amber-9" label="En attente" class="q-ml-sm" />
|
||
<q-badge v-if="a.category" :label="a.category" color="grey-3" text-color="grey-8" class="q-ml-xs" />
|
||
<q-space />
|
||
<q-btn flat round dense size="xs" icon="delete" color="grey-5" @click="actions.splice(actions.indexOf(a), 1)" />
|
||
</div>
|
||
<!-- Aperçu générique (outils STAFF dispatch/planification : create_job, assign_tech, create_recurring_shift…) -->
|
||
<div v-if="a.preview" class="text-body2 text-grey-8 q-mb-xs">{{ a.preview }}</div>
|
||
<q-badge v-if="a.severity === 'high'" color="red-1" text-color="red-9" class="q-mb-xs"><q-icon name="warning" size="13px" class="q-mr-xs" />Action conséquente</q-badge>
|
||
<!-- destinataire / client résolu -->
|
||
<div v-if="a.candidates && a.candidates.length" class="q-mb-xs">
|
||
<q-select dense outlined v-model="a.resolved" :options="a.candidates" option-label="customer_name" map-options emit-value
|
||
:display-value="a.resolved ? a.resolved.customer_name : '— aucun —'" label="Client / destinataire" @update:model-value="onResolved(a)">
|
||
<template #option="s">
|
||
<q-item v-bind="s.itemProps"><q-item-section>
|
||
<q-item-label>{{ s.opt.customer_name }}<q-badge v-if="s.opt.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></q-item-label>
|
||
<q-item-label caption>{{ s.opt.kind === 'technicien' ? '🔧 technicien · ' : '' }}{{ s.opt.address || s.opt.phone || s.opt.email }}{{ s.opt.can_sms ? ' · SMS ✓' : '' }}</q-item-label>
|
||
</q-item-section></q-item>
|
||
</template>
|
||
</q-select>
|
||
<div v-if="a.type === 'send_sms' && a.resolved && !a.resolved.can_sms" class="text-caption text-warning"><q-icon name="warning" size="13px" /> pas de mobile — SMS impossible</div>
|
||
</div>
|
||
<div v-else-if="a.phone" class="text-caption text-grey-7 q-mb-xs"><q-icon name="call" size="13px" /> {{ a.phone }}</div>
|
||
<div v-else-if="a.customer_query || a.to_query" class="text-caption text-warning q-mb-xs"><q-icon name="info" size="13px" /> « {{ a.customer_query || a.to_query }} » — non résolu</div>
|
||
<!-- Adresse de service liée (compte multi-adresses → on choisit la bonne, pas la facturation) -->
|
||
<div v-if="a.type === 'create_ticket' && a.resolved" class="q-mb-xs">
|
||
<q-select v-if="a._locs && a._locs.length > 1" dense outlined v-model="a.service_location" :options="a._locs" option-label="address" option-value="name" emit-value map-options clearable label="Adresse de service">
|
||
<template #prepend><q-icon name="place" color="warning" /></template>
|
||
</q-select>
|
||
<div v-else-if="a.resolved.address || a.service_location" class="text-caption text-grey-7"><q-icon name="place" size="13px" color="warning" /> {{ a.resolved.address || locLabel(a) }}</div>
|
||
</div>
|
||
<!-- contenu éditable -->
|
||
<q-input v-if="a.type === 'create_ticket'" v-model="a.subject" dense outlined label="Sujet" />
|
||
<q-input v-if="a.type === 'send_sms'" v-model="a.message" type="textarea" autogrow :rows="2" dense outlined label="Texto" />
|
||
<template v-if="a.type === 'send_email'"><q-input v-model="a.subject" dense outlined label="Sujet" class="q-mb-xs" /><q-input v-model="a.body" type="textarea" autogrow :rows="2" dense outlined label="Courriel" /></template>
|
||
<q-input v-if="a.type === 'note'" v-model="a.text" type="textarea" autogrow :rows="2" dense outlined label="Note" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Résultats -->
|
||
<div v-if="results.length" class="q-mt-md">
|
||
<div v-for="(r, i) in results" :key="i" class="text-body2" :class="r.ok ? 'text-green-8' : 'text-negative'">
|
||
<q-icon :name="r.ok ? 'check_circle' : 'error'" size="15px" /> {{ summaryOf(r) }}
|
||
</div>
|
||
</div>
|
||
</q-card-section>
|
||
|
||
<q-card-actions v-if="writeActions.length && !results.length" align="right">
|
||
<q-btn flat label="Annuler" @click="actions = []" />
|
||
<q-btn unelevated color="green-7" icon="send" label="Confirmer et exécuter" no-caps :loading="running" @click="run" />
|
||
</q-card-actions>
|
||
<q-card-actions v-else-if="results.length" align="right">
|
||
<q-btn unelevated color="grey-7" label="Fermer" v-close-popup />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, nextTick } from 'vue'
|
||
import { useQuasar } from 'quasar'
|
||
import { HUB_URL } from 'src/config/hub'
|
||
import { useAuthStore } from 'src/stores/auth'
|
||
|
||
const props = defineProps({
|
||
modelValue: Boolean,
|
||
// Réutilisable pour DEUX surfaces NL (cf feedback_reuse_shared_components) : l'orchestrateur COMMS
|
||
// (défauts /collab/orchestrate — tickets/SMS/courriel) ET le copilote STAFF dispatch/planif
|
||
// (plan-endpoint="/staff-agent"). Défauts = comportement comms inchangé.
|
||
initialText: { type: String, default: '' }, // pré-remplissage (ex. requête ⌘K) → auto-plan à l'ouverture
|
||
title: { type: String, default: 'Commande dictée' },
|
||
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 = [] })
|
||
}
|
||
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 () {
|
||
const h = { 'Content-Type': 'application/json' }
|
||
if (props.sendIdentity) { const u = useAuthStore().user; if (u && u !== 'authenticated') h['X-Authentik-Email'] = u }
|
||
return h
|
||
}
|
||
const planning = ref(false)
|
||
const running = ref(false)
|
||
const listening = ref(false)
|
||
const micSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition)
|
||
let recog = null
|
||
|
||
// Les vérifications (lecture) sont déjà exécutées dans le plan → séparées des actions à confirmer.
|
||
const diagnoses = computed(() => actions.value.filter(a => a.type === 'diagnose'))
|
||
const writeActions = computed(() => actions.value.filter(a => a.type !== 'diagnose'))
|
||
function dotColor (s) { return (s && s.online === true) ? '#16a34a' : (s && s.online === false) ? '#dc2626' : '#94a3b8' }
|
||
async function pickDiag (a, m) {
|
||
a.diagnostic = null
|
||
try { const r = await fetch(`${HUB_URL}/collab/service-status?customer=${encodeURIComponent(m.name)}`); if (r.ok) a.diagnostic = await r.json() } catch (e) { a.diagnostic = { ok: false, error: e.message } }
|
||
}
|
||
|
||
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 = ''; 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')
|
||
const labelOf = t => ({ create_ticket: 'Créer un ticket', send_sms: 'Envoyer un texto', send_email: 'Envoyer un courriel', note: 'Note interne', create_job: 'Créer un job', assign_tech: 'Assigner un technicien', add_assistant: 'Ajouter un assistant', set_job_status: 'Changer le statut', reschedule_job: 'Reporter le rendez-vous', create_recurring_shift: 'Horaire récurrent', follow_doc: 'Suivre' }[t] || t)
|
||
function summaryOf (r) {
|
||
if (r.type === 'create_ticket') return r.ok ? `Ticket ${r.name} créé (${r.label || ''})` : 'Ticket : ' + (r.error || 'échec')
|
||
if (r.type === 'send_sms') return r.ok ? `Texto envoyé à ${r.to} (${r.via})` : 'Texto : ' + (r.error || 'échec')
|
||
if (r.type === 'send_email') return r.ok ? `Courriel envoyé à ${r.to}` : 'Courriel : ' + (r.error || 'échec')
|
||
if (r.type === 'note') return 'Note : ' + (r.text || '')
|
||
// Actions STAFF dispatch/planif (et repli générique) : l'exécuteur renvoie { message } / { error }.
|
||
return (labelOf(r.type) || 'Action') + ' : ' + (r.message || r.error || (r.ok ? 'fait' : 'échec'))
|
||
}
|
||
|
||
function toggleMic () {
|
||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
|
||
if (!SR) { $q.notify({ type: 'warning', message: 'Dictée non supportée par ce navigateur' }); return }
|
||
if (listening.value) { try { recog && recog.stop() } catch (e) {} return }
|
||
recog = new SR(); recog.lang = 'fr-CA'; recog.interimResults = true; recog.continuous = false
|
||
recog.onresult = e => { let t = ''; for (const r of e.results) t += r[0].transcript; text.value = t }
|
||
recog.onend = () => { listening.value = false }
|
||
recog.onerror = () => { listening.value = false }
|
||
try { recog.start(); listening.value = true } catch (e) { listening.value = false }
|
||
}
|
||
|
||
async function plan () {
|
||
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: userText, history }) })
|
||
const d = await r.json()
|
||
if (d.ok) {
|
||
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 && !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) {
|
||
if (a.type !== 'create_ticket' || !a.resolved || !a.resolved.name) { a._locs = []; return }
|
||
a.service_location = a.resolved.service_location || ''
|
||
try {
|
||
const r = await fetch(`${HUB_URL}/collab/service-locations?customer=${encodeURIComponent(a.resolved.name)}`)
|
||
if (r.ok) { const d = await r.json(); a._locs = d.locations || []; if (!a.service_location && a._locs.length) a.service_location = a._locs[0].name } // adresse de service active la plus récente (triée 1re)
|
||
} catch (e) { a._locs = [] }
|
||
}
|
||
function locLabel (a) { const l = (a._locs || []).find(x => x.name === a.service_location); return l ? l.address : a.service_location }
|
||
async function run () {
|
||
running.value = true
|
||
try {
|
||
const r = await fetch(`${HUB_URL}${props.runEndpoint}`, { method: 'POST', headers: reqHeaders(), body: JSON.stringify({ actions: writeActions.value }) })
|
||
const d = await r.json()
|
||
results.value = d.results || []
|
||
const ok = results.value.filter(x => x.ok).length
|
||
$q.notify({ type: ok ? 'positive' : 'negative', message: `${ok}/${results.value.length} action(s) exécutée(s)` })
|
||
emit('done')
|
||
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { running.value = false }
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.orch-act { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #faf9ff; }
|
||
.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; }
|
||
</style>
|