feat(07-uat): admin AI panel UX enhancements + DocumentCard status indicators

- GET /api/admin/ai-config/models — fetch model list from provider's /models endpoint
- POST /api/admin/ai-config/test-connection — replaced GET; accepts unsaved form
  values (api_key, base_url, model_name) so admins can test before saving
- TestConnectionRequest Pydantic model with provider_id validation
- SearchableModelSelect.vue — new reusable combobox; Teleport to body avoids
  overflow:hidden clipping; shows all models on open, filters only on typing;
  static "Enter manually" item always pinned at bottom; caches per provider
- AdminAiConfigTab: model name input replaced with SearchableModelSelect;
  testingProvider ref + spinner added to Test Connection button
- DocumentCard: processing spinner + "Classifying…", pending "Queued" badge,
  classification_failed badge; topics section only shown when status=ready
- FileManagerView: onUnmounted imported (polling wiring pending next session)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-05 09:26:02 +02:00
co-authored by Claude Sonnet 4.6
parent ac2dded35b
commit 3b11b9a596
6 changed files with 457 additions and 29 deletions
+10 -2
View File
@@ -304,9 +304,17 @@ export function saveAiConfig(body) {
})
}
export function testAiConnection(providerId) {
export function testAiConnection(providerId, overrides = {}) {
return request('/api/admin/ai-config/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: providerId, ...overrides }),
})
}
export function getAiModels(providerId) {
return request(
'/api/admin/ai-config/test-connection?provider_id=' + encodeURIComponent(providerId),
'/api/admin/ai-config/models?provider_id=' + encodeURIComponent(providerId),
{ method: 'GET' }
)
}
@@ -74,14 +74,13 @@
/>
</div>
<!-- Model name -->
<!-- Model name searchable dropdown populated from provider's /models endpoint -->
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Model Name</label>
<input
<SearchableModelSelect
v-model="formByProvider[prov.provider_id].model_name"
type="text"
:provider-id="prov.provider_id"
:placeholder="providerDefaultModel(prov.provider_id)"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
@@ -122,10 +121,14 @@
<!-- Test Connection -->
<button
@click="runTestConnection(prov.provider_id)"
:disabled="savingProvider === prov.provider_id"
class="text-xs border border-gray-300 text-gray-700 font-semibold px-3 py-1.5 rounded-lg hover:bg-gray-100 disabled:opacity-50 transition-colors"
:disabled="testingProvider === prov.provider_id || savingProvider === prov.provider_id"
class="text-xs border border-gray-300 text-gray-700 font-semibold px-3 py-1.5 rounded-lg hover:bg-gray-100 disabled:opacity-50 flex items-center gap-1.5 transition-colors"
>
Test Connection
<span
v-if="testingProvider === prov.provider_id"
class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"
></span>
{{ testingProvider === prov.provider_id ? 'Testing' : 'Test Connection' }}
</button>
<!-- Test result badge -->
@@ -222,6 +225,7 @@
import { ref, reactive, onMounted } from 'vue'
import * as api from '../../api/client.js'
import { getAiConfig, saveAiConfig, testAiConnection } from '../../api/client.js'
import SearchableModelSelect from '../ui/SearchableModelSelect.vue'
// ── Per-provider default lookup (mirrors backend PROVIDER_DEFAULTS) ───────────
@@ -251,6 +255,7 @@ const openProviderId = ref(null)
const formByProvider = reactive({})
const testResults = reactive({})
const savingProvider = ref(null)
const testingProvider = ref(null)
function toggleProvider(pid) {
openProviderId.value = openProviderId.value === pid ? null : pid
@@ -304,11 +309,21 @@ async function saveSystemProvider(providerId, opts = {}) {
}
async function runTestConnection(providerId) {
testingProvider.value = providerId
testResults[providerId] = null
const form = formByProvider[providerId] || {}
// Pass unsaved form values so admins can test credentials before saving
const overrides = {}
if (form.api_key) overrides.api_key = form.api_key
if (form.base_url) overrides.base_url = form.base_url
if (form.model_name) overrides.model_name = form.model_name
try {
const r = await testAiConnection(providerId)
const r = await testAiConnection(providerId, overrides)
testResults[providerId] = r.ok ? 'ok' : 'failed'
} catch {
testResults[providerId] = 'failed'
} finally {
testingProvider.value = null
}
setTimeout(() => { testResults[providerId] = null }, 3000)
}
@@ -16,8 +16,8 @@
<p class="font-medium text-gray-900 text-sm truncate">{{ doc.original_name }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }}</p>
<!-- Topics -->
<div class="flex flex-wrap gap-1 mt-2">
<!-- Topics (only when classified) -->
<div v-if="doc.status === 'ready'" class="flex flex-wrap gap-1 mt-2">
<TopicBadge
v-for="topicName in doc.topics"
:key="topicName"
@@ -27,6 +27,17 @@
<span v-if="!doc.topics?.length" class="text-xs text-gray-300 italic">unclassified</span>
</div>
<!-- Processing indicator -->
<div v-else-if="doc.status === 'processing'" class="flex items-center gap-1.5 mt-2">
<span class="animate-spin rounded-full border-2 border-indigo-400 border-t-transparent w-3 h-3 shrink-0"></span>
<span class="text-xs text-indigo-500 font-medium">Classifying</span>
</div>
<!-- Pending / queued indicator -->
<div v-else-if="doc.status === 'pending'" class="mt-2">
<span class="bg-gray-100 text-gray-500 text-xs font-medium px-2 py-1 rounded-full">Queued</span>
</div>
<!-- Shared indicator pill -->
<div v-if="doc.is_shared" class="mt-2">
<span class="bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-1 rounded-full">Shared</span>
@@ -0,0 +1,277 @@
<template>
<div ref="container" class="relative">
<!-- Input: shows current value; typing filters the dropdown list -->
<div class="relative">
<input
ref="inputEl"
v-model="query"
type="text"
:placeholder="placeholder"
class="block w-full rounded-lg px-3 py-2 pr-8 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
autocomplete="off"
@focus="handleFocus"
@blur="handleBlur"
@keydown.down.prevent="moveDown"
@keydown.up.prevent="moveUp"
@keydown.enter.prevent="selectHighlighted"
@keydown.escape="close"
@input="onInput"
/>
<!-- Chevron toggle button -->
<button
type="button"
tabindex="-1"
class="absolute inset-y-0 right-0 flex items-center px-2 text-gray-400 hover:text-gray-600"
@mousedown.prevent="toggleOpen"
>
<svg
:class="['w-4 h-4 transition-transform', isOpen ? 'rotate-180' : '']"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<!-- Dropdown teleported to body to avoid overflow:hidden clipping in parent sections -->
<Teleport to="body">
<ul
v-if="isOpen"
ref="listEl"
:style="dropdownStyle"
class="fixed z-[9999] bg-white border border-gray-200 rounded-lg shadow-xl overflow-y-auto"
style="max-height: 240px;"
@mousedown.prevent
>
<!-- Loading state -->
<li v-if="loading" class="flex items-center gap-2 px-3 py-2 text-sm text-gray-400">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Loading models
</li>
<!-- Error state -->
<li v-else-if="fetchError && filtered.length === 0" class="px-3 py-2 text-sm text-amber-600">
Could not fetch models type a name manually below.
</li>
<!-- Model list -->
<template v-else>
<li
v-for="(model, i) in filtered"
:key="model"
:class="[
'px-3 py-2 text-sm cursor-pointer select-none',
i === highlighted ? 'bg-indigo-50 text-indigo-900' : 'text-gray-800 hover:bg-gray-50'
]"
@mousedown.prevent="selectModel(model)"
>
{{ model }}
</li>
<!-- No matches hint (only when query typed but nothing matches) -->
<li
v-if="filtered.length === 0 && query.trim()"
class="px-3 py-2 text-xs text-gray-400 italic"
>
No matches
</li>
</template>
<!-- Static "manual entry" always last, always visible regardless of filter -->
<li
class="sticky bottom-0 flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 font-medium border-t border-gray-100 bg-white cursor-pointer hover:bg-indigo-50 select-none"
@mousedown.prevent="selectManual"
>
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
<span>{{ query.trim() ? `Use "${query.trim()}"` : 'Enter model name manually' }}</span>
</li>
</ul>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
import { getAiModels } from '../../api/client.js'
const props = defineProps({
modelValue: { type: String, default: '' },
providerId: { type: String, required: true },
placeholder: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
// ── State ─────────────────────────────────────────────────────────────────────
const container = ref(null)
const inputEl = ref(null)
const listEl = ref(null)
const isOpen = ref(false)
const query = ref(props.modelValue || '') // displayed value in the input
const filterText = ref('') // separate filter — only set when user types
const hasTyped = ref(false) // false on open → show all models unfiltered
const models = ref([])
const loading = ref(false)
const fetchError = ref(null)
const highlighted = ref(-1)
const dropdownStyle = ref({})
// Cache fetched models per providerId so we don't re-fetch on each open
const fetchedFor = ref(null)
// ── Sync external modelValue → internal query when parent changes the value ───
watch(() => props.modelValue, (val) => {
if (val !== query.value) query.value = val || ''
})
// ── Filtered list ─────────────────────────────────────────────────────────────
// Show ALL models when the dropdown first opens (hasTyped=false).
// Only filter once the user starts typing so the pre-filled value
// doesn't hide every model that doesn't match the current selection.
const filtered = computed(() => {
if (!hasTyped.value) return models.value
const q = filterText.value.trim().toLowerCase()
if (!q) return models.value
return models.value.filter(m => m.toLowerCase().includes(q))
})
// ── Dropdown position (recalculate from input bounding rect) ──────────────────
function updatePosition() {
if (!inputEl.value) return
const rect = inputEl.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropH = Math.min(240, 40 + filtered.value.length * 36 + 40)
// Prefer opening below; flip above if not enough space
if (spaceBelow >= dropH || spaceBelow > 120) {
dropdownStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
}
} else {
dropdownStyle.value = {
bottom: `${window.innerHeight - rect.top + 4}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
}
}
}
// ── Fetch models from backend ─────────────────────────────────────────────────
async function fetchModels() {
if (fetchedFor.value === props.providerId) return // already cached for this provider
loading.value = true
fetchError.value = null
try {
const data = await getAiModels(props.providerId)
models.value = data.models || []
fetchedFor.value = props.providerId
if (data.error) fetchError.value = data.error
} catch (e) {
fetchError.value = e.message || 'fetch failed'
models.value = []
} finally {
loading.value = false
}
}
// ── Open / close ──────────────────────────────────────────────────────────────
async function open() {
isOpen.value = true
highlighted.value = -1
hasTyped.value = false // reset so all models are shown on open
filterText.value = ''
await nextTick()
updatePosition()
fetchModels()
}
function close() {
isOpen.value = false
highlighted.value = -1
hasTyped.value = false
filterText.value = ''
}
function toggleOpen() {
if (isOpen.value) {
close()
} else {
inputEl.value?.focus()
open()
}
}
function handleFocus() {
open()
}
// Delay close so mousedown on list items fires first
function handleBlur() {
setTimeout(close, 150)
}
// ── Input handler — emit new value as user types ──────────────────────────────
function onInput() {
hasTyped.value = true
filterText.value = query.value
emit('update:modelValue', query.value)
highlighted.value = -1
if (!isOpen.value) open()
}
// ── Selection ─────────────────────────────────────────────────────────────────
function selectModel(model) {
query.value = model
emit('update:modelValue', model)
close()
}
function selectManual() {
// Commit whatever is currently typed and close
emit('update:modelValue', query.value)
close()
}
// ── Keyboard navigation ───────────────────────────────────────────────────────
function moveDown() {
if (!isOpen.value) { open(); return }
const max = filtered.value.length // +1 for manual entry item, but we skip it in keyboard nav
highlighted.value = highlighted.value < max - 1 ? highlighted.value + 1 : 0
}
function moveUp() {
if (!isOpen.value) return
const max = filtered.value.length
highlighted.value = highlighted.value > 0 ? highlighted.value - 1 : max - 1
}
function selectHighlighted() {
if (highlighted.value >= 0 && highlighted.value < filtered.value.length) {
selectModel(filtered.value[highlighted.value])
} else {
selectManual()
}
}
// ── Reposition on scroll/resize so dropdown tracks input ─────────────────────
function onScroll() { if (isOpen.value) updatePosition() }
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
onUnmounted(() => {
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
</script>
+1 -1
View File
@@ -44,7 +44,7 @@
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFoldersStore } from '../stores/folders.js'
import { useDocumentsStore } from '../stores/documents.js'