feat: first IA page

This commit is contained in:
2026-02-13 12:18:24 +01:00
parent 5c9a8c9b5e
commit 4fe6a4b2c9
10 changed files with 910 additions and 3 deletions

View File

@@ -34,14 +34,22 @@
Revenus
</router-link>
<router-link
to="/analytics"
<router-link
to="/analytics"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/analytics' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
Depenses
</router-link>
<router-link
to="/ia"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/ia' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
IA
</router-link>
<!-- Import button with file input -->
<label
class="px-3 py-1.5 text-sm rounded transition-colors cursor-pointer bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white"

View File

@@ -0,0 +1,313 @@
<template>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-5xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white">Assistant IA</h1>
<p class="text-gray-400 text-sm mt-1">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="bg-gray-800 rounded-lg border border-gray-700 p-4">
<div class="flex gap-3">
<input
v-model="question"
type="text"
placeholder="Posez votre question..."
class="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors"
@keydown.enter="askQuestion"
:disabled="loading"
/>
<select
v-model="chartType"
class="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-blue-500 transition-colors"
: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="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700"
>
{{ loading ? 'Analyse...' : 'Demander' }}
</button>
</div>
</div>
<!-- Exemples de questions -->
<div v-if="!result && !loading && !error" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<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="px-3 py-1.5 text-sm bg-gray-900 border border-gray-700 rounded-lg text-gray-300 hover:border-blue-500 hover:text-white transition-colors"
>
{{ example }}
</button>
</div>
</div>
<!-- Spinner -->
<div v-if="loading" class="bg-gray-800 rounded-lg border border-gray-700 p-8 text-center">
<div class="inline-block w-8 h-8 border-2 border-gray-600 border-t-blue-500 rounded-full animate-spin 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-4">
<!-- Réponse texte -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-2">Réponse</h2>
<p class="text-white whitespace-pre-wrap">{{ result.answer }}</p>
</div>
<!-- Requête SQL -->
<div v-if="result.sql_query" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-2">Requête SQL exécutée</h2>
<pre class="text-xs text-gray-300 font-mono bg-gray-900 rounded p-3 overflow-x-auto">{{ result.sql_query }}</pre>
</div>
<!-- Graphique -->
<div v-if="result.chart_config && chartComponent" class="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-3">Graphique</h2>
<div class="bg-gray-900 rounded-lg p-4">
<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="bg-gray-800 rounded-lg border border-gray-700 p-4">
<h2 class="text-sm font-medium text-gray-400 mb-3">
Données ({{ result.data.row_count }} ligne{{ result.data.row_count > 1 ? 's' : '' }})
</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-700">
<th
v-for="col in result.data.columns"
:key="col"
class="text-left px-3 py-2 text-gray-400 font-medium"
>
{{ col }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, idx) in result.data.rows"
:key="idx"
class="border-b border-gray-700/50 hover:bg-gray-700/30"
>
<td
v-for="(cell, cidx) in row"
:key="cidx"
class="px-3 py-2 text-gray-300"
>
{{ 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>

View File

@@ -5,6 +5,7 @@ import AnalyticsPage from './pages/AnalyticsPage.vue'
import RevenusPage from './pages/RevenusPage.vue'
import DocumentsPage from './pages/DocumentsPage.vue'
import EditDocumentPage from './pages/EditDocumentPage.vue'
import IAPage from './pages/IAPage.vue'
const routes = [
{
@@ -27,6 +28,11 @@ const routes = [
name: 'revenus',
component: RevenusPage
},
{
path: '/ia',
name: 'ia',
component: IAPage
},
{
path: '/documents',
name: 'documents',