Chaque page plafonnait sa largeur differemment (max-w-4xl pour Config, 5xl pour IA, 6xl pour l'accueil et les documents, 7xl pour les revenus et les depenses) : sur un ecran large, les donnees se tassaient au centre. Les pages n'ont plus de plafond, le contenu occupe l'ecran avec une marge laterale fixe. Pour tirer parti de la place gagnee : Config affiche ses deux sections cote a cote, et le split apercu PDF / donnees passe de 50-50 a 40-60 en faveur des donnees. Pendant le tagging, le bouton « Annuler » de l'en-tete d'edition est masque : il faisait doublon avec celui du bandeau de tagging, qui a un sens different (retour a l'edition, et non abandon). La sortie vers la liste reste le fil d'Ariane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
8.6 KiB
Vue
312 lines
8.6 KiB
Vue
<template>
|
|
<div class="page">
|
|
<div class="page-content">
|
|
<!-- Header -->
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-title">Assistant IA</h1>
|
|
<p class="page-subtitle">Posez des questions sur vos données en langage naturel</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="inline-block w-2.5 h-2.5 rounded-full"
|
|
:class="ollamaAvailable ? 'bg-green-400' : 'bg-red-400'"
|
|
></span>
|
|
<span class="text-sm" :class="ollamaAvailable ? 'text-green-400' : 'text-red-400'">
|
|
{{ ollamaAvailable ? `Ollama (${ollamaModel})` : 'Ollama indisponible' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Formulaire -->
|
|
<div class="card card-body">
|
|
<div class="flex gap-3">
|
|
<input
|
|
v-model="question"
|
|
type="text"
|
|
placeholder="Posez votre question..."
|
|
class="input"
|
|
@keydown.enter="askQuestion"
|
|
:disabled="loading"
|
|
/>
|
|
<select
|
|
v-model="chartType"
|
|
class="input w-auto"
|
|
:disabled="loading"
|
|
>
|
|
<option value="auto">Auto</option>
|
|
<option value="bar">Barres</option>
|
|
<option value="line">Lignes</option>
|
|
<option value="pie">Camembert</option>
|
|
<option value="doughnut">Anneau</option>
|
|
</select>
|
|
<button
|
|
@click="askQuestion"
|
|
:disabled="!question.trim() || loading"
|
|
class="btn btn-primary whitespace-nowrap"
|
|
>
|
|
{{ loading ? 'Analyse...' : 'Demander' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Exemples de questions -->
|
|
<div v-if="!result && !loading && !error" class="card card-body">
|
|
<p class="text-gray-400 text-sm mb-3">Exemples de questions :</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
v-for="example in examples"
|
|
:key="example"
|
|
@click="question = example; askQuestion()"
|
|
class="btn btn-secondary"
|
|
>
|
|
{{ example }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Spinner -->
|
|
<div v-if="loading" class="card empty-state">
|
|
<div class="spinner w-8 h-8 mb-3"></div>
|
|
<p class="text-gray-400">{{ loadingMessage }}</p>
|
|
</div>
|
|
|
|
<!-- Erreur -->
|
|
<div v-if="error" class="bg-red-900/20 border border-red-800 rounded-lg p-4">
|
|
<p class="text-red-400">{{ error }}</p>
|
|
</div>
|
|
|
|
<!-- Résultat -->
|
|
<div v-if="result" class="space-y-6">
|
|
<!-- Réponse texte -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Réponse</h2>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-white whitespace-pre-wrap">{{ result.answer }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Requête SQL -->
|
|
<div v-if="result.sql_query" class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Requête SQL exécutée</h2>
|
|
</div>
|
|
<div class="card-body">
|
|
<pre class="text-xs text-gray-300 font-mono bg-gray-950 border border-gray-700 rounded p-3 overflow-x-auto">{{ result.sql_query }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Graphique -->
|
|
<div v-if="result.chart_config && chartComponent" class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Graphique</h2>
|
|
</div>
|
|
<div class="card-body">
|
|
<component
|
|
:is="chartComponent"
|
|
:data="result.chart_config.data"
|
|
:options="mergedChartOptions"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tableau de données -->
|
|
<div v-if="result.data && result.data.columns && result.data.rows && result.data.rows.length > 0" class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">
|
|
Données ({{ result.data.row_count }} ligne{{ result.data.row_count > 1 ? 's' : '' }})
|
|
</h2>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th v-for="col in result.data.columns" :key="col">{{ col }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, idx) in result.data.rows" :key="idx">
|
|
<td v-for="(cell, cidx) in row" :key="cidx">
|
|
{{ formatCell(cell) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { Bar, Line, Doughnut, Pie } from 'vue-chartjs'
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale,
|
|
LinearScale,
|
|
BarElement,
|
|
LineElement,
|
|
PointElement,
|
|
ArcElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler,
|
|
} from 'chart.js'
|
|
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
BarElement,
|
|
LineElement,
|
|
PointElement,
|
|
ArcElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler,
|
|
)
|
|
|
|
const question = ref('')
|
|
const chartType = ref('auto')
|
|
const loading = ref(false)
|
|
const loadingMessage = ref('Envoi de la question...')
|
|
const result = ref(null)
|
|
const error = ref(null)
|
|
const ollamaAvailable = ref(false)
|
|
const ollamaModel = ref('')
|
|
|
|
const examples = [
|
|
'Combien d\'immeubles dans la base ?',
|
|
'Total des dépenses par immeuble ?',
|
|
'Top 5 fournisseurs les plus chers ?',
|
|
'Évolution mensuelle des revenus ?',
|
|
'Quels sont les locataires avec des impayés ?',
|
|
'Répartition des dépenses par catégorie ?',
|
|
]
|
|
|
|
const chartComponentMap = {
|
|
bar: Bar,
|
|
line: Line,
|
|
doughnut: Doughnut,
|
|
pie: Pie,
|
|
}
|
|
|
|
const chartComponent = computed(() => {
|
|
if (!result.value?.chart_config?.type) return null
|
|
return chartComponentMap[result.value.chart_config.type] || null
|
|
})
|
|
|
|
const darkThemeDefaults = {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
plugins: {
|
|
legend: {
|
|
labels: { color: 'rgb(156, 163, 175)' },
|
|
},
|
|
title: {
|
|
color: 'rgb(229, 231, 235)',
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: { color: 'rgb(156, 163, 175)' },
|
|
grid: { color: 'rgb(55, 65, 81)' },
|
|
},
|
|
y: {
|
|
ticks: { color: 'rgb(156, 163, 175)' },
|
|
grid: { color: 'rgb(55, 65, 81)' },
|
|
},
|
|
},
|
|
}
|
|
|
|
const mergedChartOptions = computed(() => {
|
|
const llmOptions = result.value?.chart_config?.options || {}
|
|
const chartType = result.value?.chart_config?.type
|
|
// Doughnut/Pie don't use scales
|
|
if (chartType === 'doughnut' || chartType === 'pie') {
|
|
const { scales, ...defaults } = darkThemeDefaults
|
|
return deepMerge(defaults, llmOptions)
|
|
}
|
|
return deepMerge(darkThemeDefaults, llmOptions)
|
|
})
|
|
|
|
function deepMerge(target, source) {
|
|
const output = { ...target }
|
|
for (const key of Object.keys(source)) {
|
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
output[key] = deepMerge(output[key] || {}, source[key])
|
|
} else {
|
|
output[key] = source[key]
|
|
}
|
|
}
|
|
return output
|
|
}
|
|
|
|
function formatCell(value) {
|
|
if (value === null || value === undefined) return '—'
|
|
if (typeof value === 'number') {
|
|
return Number.isInteger(value) ? value.toString() : value.toFixed(2)
|
|
}
|
|
return String(value)
|
|
}
|
|
|
|
async function checkHealth() {
|
|
try {
|
|
const resp = await fetch('/api/ia/health')
|
|
if (resp.ok) {
|
|
const data = await resp.json()
|
|
ollamaAvailable.value = data.ollama_available
|
|
ollamaModel.value = data.model
|
|
}
|
|
} catch {
|
|
ollamaAvailable.value = false
|
|
}
|
|
}
|
|
|
|
async function askQuestion() {
|
|
const q = question.value.trim()
|
|
if (!q || loading.value) return
|
|
|
|
loading.value = true
|
|
loadingMessage.value = 'Envoi de la question...'
|
|
result.value = null
|
|
error.value = null
|
|
|
|
// Messages progressifs
|
|
const timer = setTimeout(() => {
|
|
loadingMessage.value = 'Analyse des données en cours...'
|
|
}, 3000)
|
|
const timer2 = setTimeout(() => {
|
|
loadingMessage.value = 'Génération de la réponse...'
|
|
}, 8000)
|
|
|
|
try {
|
|
const resp = await fetch('/api/ia/ask', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ question: q, chart_type: chartType.value }),
|
|
})
|
|
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => ({}))
|
|
throw new Error(data.detail || `Erreur ${resp.status}`)
|
|
}
|
|
|
|
result.value = await resp.json()
|
|
} catch (e) {
|
|
error.value = e.message || 'Une erreur est survenue.'
|
|
} finally {
|
|
clearTimeout(timer)
|
|
clearTimeout(timer2)
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(checkHealth)
|
|
</script>
|