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:
@@ -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"
|
||||
|
||||
292
frontend/src/pages/ConfigPage.vue
Normal file
292
frontend/src/pages/ConfigPage.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user