feat: add config page with LLM settings and tag management

Add ConfigPage.vue with two sections: Ollama configuration (URL, model,
timeout) with source badges and per-field reset, and tag management with
inline rename and creation. Add /config route and nav link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 16:09:37 +01:00
parent a1d708d083
commit bd8b9a044c
3 changed files with 306 additions and 0 deletions

View File

@@ -50,6 +50,14 @@
IA
</router-link>
<router-link
to="/config"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/config' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
Config
</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,292 @@
<template>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-white">Configuration</h1>
<p class="text-gray-400 text-sm mt-1">Paramètres LLM et gestion des tags</p>
</div>
<!-- Section LLM -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Configuration LLM (Ollama)</h2>
<div class="space-y-4">
<div v-for="field in settingsFields" :key="field.key" class="flex items-end gap-3">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<label class="text-sm font-medium text-gray-300">{{ field.label }}</label>
<span
class="text-xs px-1.5 py-0.5 rounded"
:class="sourceBadgeClass(settingsSources[field.key])"
>{{ settingsSources[field.key] || 'default' }}</span>
</div>
<input
v-model="settingsForm[field.key]"
:type="field.type || 'text'"
:placeholder="field.placeholder"
class="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors"
/>
</div>
<button
@click="resetSetting(field.key)"
class="px-3 py-2 text-sm text-gray-400 border border-gray-600 rounded-lg hover:text-white hover:border-gray-500 transition-colors"
title="Reset au défaut"
>Reset</button>
</div>
</div>
<div class="mt-4 flex items-center gap-3">
<button
@click="saveSettings"
:disabled="savingSettings"
class="px-5 py-2 bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 hover:bg-blue-700"
>{{ savingSettings ? 'Enregistrement...' : 'Enregistrer' }}</button>
<span v-if="settingsMessage" class="text-sm" :class="settingsMessageError ? 'text-red-400' : 'text-green-400'">{{ settingsMessage }}</span>
</div>
</div>
<!-- Section Tags -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Gestion des tags</h2>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-700">
<th class="text-left py-2 text-gray-400 font-medium">ID</th>
<th class="text-left py-2 text-gray-400 font-medium">Nom</th>
<th class="text-right py-2 text-gray-400 font-medium">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags" :key="tag.id" class="border-b border-gray-700/50">
<td class="py-2 text-gray-500">{{ tag.id }}</td>
<td class="py-2">
<input
v-if="editingTagId === tag.id"
v-model="editingTagNom"
class="bg-gray-900 border border-blue-500 rounded px-2 py-1 text-white text-sm focus:outline-none"
@keydown.enter="confirmRenameTag(tag.id)"
@keydown.escape="cancelEditTag"
ref="editInput"
/>
<span v-else class="text-white">{{ tag.nom }}</span>
</td>
<td class="py-2 text-right">
<template v-if="editingTagId === tag.id">
<button
@click="confirmRenameTag(tag.id)"
class="text-green-400 hover:text-green-300 text-sm mr-2"
>Valider</button>
<button
@click="cancelEditTag"
class="text-gray-400 hover:text-gray-300 text-sm"
>Annuler</button>
</template>
<button
v-else
@click="startEditTag(tag)"
class="text-blue-400 hover:text-blue-300 text-sm"
>Renommer</button>
</td>
</tr>
</tbody>
</table>
<!-- Ajout d'un nouveau tag -->
<div class="mt-4 flex gap-3">
<input
v-model="newTagNom"
type="text"
placeholder="Nouveau tag..."
class="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors text-sm"
@keydown.enter="createTag"
/>
<button
@click="createTag"
:disabled="!newTagNom.trim()"
class="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 hover:bg-green-700"
>Ajouter</button>
</div>
<p v-if="tagMessage" class="mt-2 text-sm" :class="tagMessageError ? 'text-red-400' : 'text-green-400'">{{ tagMessage }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
const API = import.meta.env.VITE_API_URL || ''
// ============================================================
// Settings
// ============================================================
const settingsFields = [
{ key: 'ollama_url', label: 'URL Ollama', placeholder: 'http://localhost:11434' },
{ key: 'ollama_model', label: 'Modèle', placeholder: 'qwen2.5' },
{ key: 'ollama_timeout', label: 'Timeout (secondes)', placeholder: '120', type: 'number' },
]
const settingsForm = ref({
ollama_url: '',
ollama_model: '',
ollama_timeout: '',
})
const settingsSources = ref({})
const savingSettings = ref(false)
const settingsMessage = ref('')
const settingsMessageError = ref(false)
async function loadSettings() {
try {
const resp = await fetch(`${API}/api/config/settings`)
const data = await resp.json()
for (const [key, info] of Object.entries(data)) {
settingsForm.value[key] = info.value
settingsSources.value[key] = info.source
}
} catch (e) {
console.error('Failed to load settings', e)
}
}
async function saveSettings() {
savingSettings.value = true
settingsMessage.value = ''
try {
for (const field of settingsFields) {
await fetch(`${API}/api/config/settings/${field.key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(settingsForm.value[field.key]) }),
})
}
await loadSettings()
settingsMessage.value = 'Paramètres enregistrés'
settingsMessageError.value = false
} catch (e) {
settingsMessage.value = 'Erreur lors de l\'enregistrement'
settingsMessageError.value = true
} finally {
savingSettings.value = false
}
}
async function resetSetting(key) {
try {
await fetch(`${API}/api/config/settings/${key}`, { method: 'DELETE' })
await loadSettings()
settingsMessage.value = `"${key}" remis au défaut`
settingsMessageError.value = false
} catch (e) {
settingsMessage.value = `Erreur lors du reset de "${key}"`
settingsMessageError.value = true
}
}
function sourceBadgeClass(source) {
if (source === 'database') return 'bg-blue-600/30 text-blue-400'
if (source === 'env') return 'bg-yellow-600/30 text-yellow-400'
return 'bg-gray-600/30 text-gray-400'
}
// ============================================================
// Tags
// ============================================================
const tags = ref([])
const editingTagId = ref(null)
const editingTagNom = ref('')
const newTagNom = ref('')
const tagMessage = ref('')
const tagMessageError = ref(false)
const editInput = ref(null)
async function loadTags() {
try {
const resp = await fetch(`${API}/api/config/tags`)
tags.value = await resp.json()
} catch (e) {
console.error('Failed to load tags', e)
}
}
function startEditTag(tag) {
editingTagId.value = tag.id
editingTagNom.value = tag.nom
nextTick(() => {
if (editInput.value) {
const input = Array.isArray(editInput.value) ? editInput.value[0] : editInput.value
input?.focus()
}
})
}
function cancelEditTag() {
editingTagId.value = null
editingTagNom.value = ''
}
async function confirmRenameTag(tagId) {
const nom = editingTagNom.value.trim()
if (!nom) return
tagMessage.value = ''
try {
const resp = await fetch(`${API}/api/config/tags/${tagId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nom }),
})
if (!resp.ok) {
const err = await resp.json()
tagMessage.value = err.detail || 'Erreur'
tagMessageError.value = true
return
}
cancelEditTag()
await loadTags()
tagMessage.value = 'Tag renommé'
tagMessageError.value = false
} catch (e) {
tagMessage.value = 'Erreur lors du renommage'
tagMessageError.value = true
}
}
async function createTag() {
const nom = newTagNom.value.trim()
if (!nom) return
tagMessage.value = ''
try {
const resp = await fetch(`${API}/api/config/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nom }),
})
if (!resp.ok) {
const err = await resp.json()
tagMessage.value = err.detail || 'Erreur'
tagMessageError.value = true
return
}
newTagNom.value = ''
await loadTags()
tagMessage.value = 'Tag créé'
tagMessageError.value = false
} catch (e) {
tagMessage.value = 'Erreur lors de la création'
tagMessageError.value = true
}
}
// ============================================================
// Init
// ============================================================
onMounted(() => {
loadSettings()
loadTags()
})
</script>

View File

@@ -6,6 +6,7 @@ import RevenusPage from './pages/RevenusPage.vue'
import DocumentsPage from './pages/DocumentsPage.vue'
import EditDocumentPage from './pages/EditDocumentPage.vue'
import IAPage from './pages/IAPage.vue'
import ConfigPage from './pages/ConfigPage.vue'
const routes = [
{
@@ -33,6 +34,11 @@ const routes = [
name: 'ia',
component: IAPage
},
{
path: '/config',
name: 'config',
component: ConfigPage
},
{
path: '/documents',
name: 'documents',