feat: first IA page
This commit is contained in:
@@ -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"
|
||||
|
||||
313
frontend/src/pages/IAPage.vue
Normal file
313
frontend/src/pages/IAPage.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user