gigafibre-fsm/apps/ops/src/components/dispatch/NlpInput.vue
louispaulb 10d0854e51 refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00

167 lines
6.4 KiB
Vue

<template>
<div class="nlp-input-bar" :class="{ 'nlp-expanded': showResult }">
<div class="nlp-row">
<q-icon name="auto_awesome" size="18px" color="indigo-5" class="q-mr-xs" />
<input
ref="inputRef"
v-model="text"
class="nlp-text"
:placeholder="placeholder"
@keydown.enter="submit"
@keydown.escape="clear"
:disabled="loading"
/>
<q-spinner v-if="loading" size="16px" color="indigo-5" class="q-mx-xs" />
<q-btn v-else-if="text.trim()" flat dense round size="sm" icon="send" color="indigo-6" @click="submit" />
<q-btn v-if="showResult" flat dense round size="xs" icon="close" color="grey-6" @click="clear" />
</div>
<transition name="nlp-slide">
<div v-if="showResult && result" class="nlp-result">
<div class="nlp-result-action">
<q-icon :name="actionIcon" :color="actionColor" size="16px" class="q-mr-xs" />
<span class="text-weight-bold">{{ result.action_label || result.action }}</span>
<q-badge v-if="result.confidence" :color="result.confidence > 0.8 ? 'green' : result.confidence > 0.5 ? 'orange' : 'red'" class="q-ml-sm">
{{ Math.round(result.confidence * 100) }}%
</q-badge>
</div>
<div v-if="result.explanation" class="text-caption text-grey-7 q-mt-xs">{{ result.explanation }}</div>
<!-- Action-specific details -->
<div v-if="result.action === 'create_job'" class="nlp-details q-mt-sm">
<div v-if="result.subject" class="nlp-detail"><span class="nlp-label">Sujet:</span> {{ result.subject }}</div>
<div v-if="result.tech" class="nlp-detail"><span class="nlp-label">Tech:</span> {{ result.tech }}</div>
<div v-if="result.date" class="nlp-detail"><span class="nlp-label">Date:</span> {{ result.date }}</div>
<div v-if="result.time" class="nlp-detail"><span class="nlp-label">Heure:</span> {{ result.time }}</div>
<div v-if="result.duration" class="nlp-detail"><span class="nlp-label">Durée:</span> {{ result.duration }}h</div>
<div v-if="result.address" class="nlp-detail"><span class="nlp-label">Adresse:</span> {{ result.address }}</div>
</div>
<div v-if="result.action === 'move_job'" class="nlp-details q-mt-sm">
<div v-if="result.from_tech" class="nlp-detail"><span class="nlp-label">De:</span> {{ result.from_tech }}</div>
<div v-if="result.to_tech" class="nlp-detail"><span class="nlp-label">Vers:</span> {{ result.to_tech }}</div>
</div>
<div v-if="result.action === 'redistribute'" class="nlp-details q-mt-sm">
<div v-if="result.absent_tech" class="nlp-detail"><span class="nlp-label">Absent:</span> {{ result.absent_tech }}</div>
</div>
<div class="nlp-actions q-mt-sm">
<q-btn v-if="result.action !== 'query' && result.action !== 'unknown'" dense no-caps unelevated
color="indigo-6" size="sm" icon="check" label="Appliquer" @click="apply" :loading="applying" />
<q-btn flat dense no-caps size="sm" label="Ignorer" color="grey-6" @click="clear" />
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
const props = defineProps({
techNames: { type: Array, default: () => [] },
})
const emit = defineEmits(['action', 'applied'])
const $q = useQuasar()
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const inputRef = ref(null)
const text = ref('')
const loading = ref(false)
const applying = ref(false)
const result = ref(null)
const showResult = computed(() => !!result.value)
const placeholder = 'Ex: "Mets une install chez 123 rue Laval demain 9h pour Marc" ou "Marc est malade, redistribue"...'
const ACTION_ICONS = {
create_job: 'add_task',
move_job: 'swap_horiz',
redistribute: 'group_work',
cancel_job: 'cancel',
query: 'search',
unknown: 'help_outline',
}
const ACTION_COLORS = {
create_job: 'indigo-6',
move_job: 'blue-6',
redistribute: 'orange-8',
cancel_job: 'red',
query: 'grey-7',
unknown: 'grey-5',
}
const actionIcon = computed(() => ACTION_ICONS[result.value?.action] || 'help_outline')
const actionColor = computed(() => ACTION_COLORS[result.value?.action] || 'grey-5')
async function submit () {
const input = text.value.trim()
if (!input || loading.value) return
loading.value = true
result.value = null
try {
const res = await fetch(`${HUB_URL}/ai/dispatch-nlp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: input, tech_names: props.techNames }),
})
if (!res.ok) throw new Error('Erreur AI')
const data = await res.json()
result.value = data
emit('action', data)
} catch (e) {
$q.notify({ type: 'negative', message: `NLP: ${e.message}` })
} finally {
loading.value = false
}
}
function apply () {
if (!result.value) return
applying.value = true
emit('applied', result.value)
// Parent handles the actual dispatch action
setTimeout(() => {
applying.value = false
clear()
}, 500)
}
function clear () {
result.value = null
text.value = ''
inputRef.value?.focus()
}
defineExpose({ focus: () => inputRef.value?.focus() })
</script>
<style scoped>
.nlp-input-bar {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
transition: all 0.2s ease;
}
.nlp-input-bar:focus-within { border-color: #818cf8; box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.15); }
.nlp-expanded { background: #fff; }
.nlp-row { display: flex; align-items: center; padding: 6px 10px; }
.nlp-text {
flex: 1; border: none; outline: none; background: transparent;
font-size: 0.84rem; color: #1e293b; font-family: inherit;
}
.nlp-text::placeholder { color: #94a3b8; }
.nlp-result { padding: 8px 12px; border-top: 1px solid #e2e8f0; }
.nlp-result-action { display: flex; align-items: center; }
.nlp-details { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.nlp-detail { font-size: 0.78rem; color: #475569; }
.nlp-label { font-weight: 600; color: #94a3b8; font-size: 0.72rem; }
.nlp-actions { display: flex; gap: 8px; }
.nlp-slide-enter-active, .nlp-slide-leave-active { transition: max-height 0.2s ease, opacity 0.15s ease; overflow: hidden; }
.nlp-slide-enter-from, .nlp-slide-leave-to { max-height: 0; opacity: 0; }
.nlp-slide-enter-to, .nlp-slide-leave-from { max-height: 300px; opacity: 1; }
</style>