feat(07-05): frontend AI config client helpers + AdminAiConfigTab system section + Vitest tests
- Add getAiConfig/saveAiConfig/testAiConnection to frontend/src/api/client.js - AdminAiConfigTab.vue: add System AI Providers section above existing per-user table - Per-provider accordion (10 providers from PROVIDER_DEFAULTS) - Write-only API key field (never pre-filled), base URL, model, context_chars inputs - Set Active (atomic flip), Save, Test Connection buttons with inline badges - Existing per-user ai-config table and saveConfig logic untouched (Pitfall 6) - Create frontend/tests/api.spec.js: 4 Vitest tests for getAiConfig/saveAiConfig/testAiConnection - All 127 frontend tests pass, build exits 0 (D-11 frontend coverage)
This commit is contained in:
@@ -290,6 +290,27 @@ export function adminDeleteUser(id, adminPassword) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── System AI Provider Configuration (D-08, D-15) ───────────────────────────
|
||||||
|
|
||||||
|
export function getAiConfig() {
|
||||||
|
return request('/api/admin/ai-config', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAiConfig(body) {
|
||||||
|
return request('/api/admin/ai-config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testAiConnection(providerId) {
|
||||||
|
return request(
|
||||||
|
'/api/admin/ai-config/test-connection?provider_id=' + encodeURIComponent(providerId),
|
||||||
|
{ method: 'GET' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Folders ───────────────────────────────────────────────────────────────────
|
// ── Folders ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function listFolders(parentId = null) {
|
export function listFolders(parentId = null) {
|
||||||
|
|||||||
@@ -1,5 +1,150 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- ── System AI Providers (Global) ───────────────────────────────────── -->
|
||||||
|
<section class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-900">System AI Providers (Global)</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">
|
||||||
|
These settings apply to all classification jobs. Per-user overrides below take precedence when set.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading spinner -->
|
||||||
|
<div v-if="loadingSystem" class="p-6 text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
|
||||||
|
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
|
||||||
|
Loading AI providers…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System error banner -->
|
||||||
|
<div v-if="systemError" class="mx-6 mt-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{{ systemError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider accordion list -->
|
||||||
|
<div v-if="!loadingSystem" class="divide-y divide-gray-100">
|
||||||
|
<div
|
||||||
|
v-for="prov in systemProviders"
|
||||||
|
:key="prov.provider_id"
|
||||||
|
:class="['transition-colors', prov.is_active ? 'border-l-4 border-l-green-500' : '']"
|
||||||
|
>
|
||||||
|
<!-- Accordion header -->
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between px-6 py-3 text-left hover:bg-gray-50 transition-colors"
|
||||||
|
@click="toggleProvider(prov.provider_id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-gray-900 capitalize">{{ prov.provider_id }}</span>
|
||||||
|
<span
|
||||||
|
v-if="prov.is_active"
|
||||||
|
class="bg-green-100 text-green-700 text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||||
|
>Active</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
:class="['w-4 h-4 text-gray-400 transition-transform', openProviderId === prov.provider_id ? '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>
|
||||||
|
|
||||||
|
<!-- Accordion body -->
|
||||||
|
<div v-if="openProviderId === prov.provider_id" class="px-6 pb-5 pt-2 bg-gray-50 space-y-3">
|
||||||
|
<!-- API Key -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">API Key</label>
|
||||||
|
<input
|
||||||
|
v-model="formByProvider[prov.provider_id].api_key"
|
||||||
|
type="password"
|
||||||
|
:placeholder="prov.has_api_key ? '(unchanged)' : '(not set)'"
|
||||||
|
autocomplete="off"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Base URL -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Base URL</label>
|
||||||
|
<input
|
||||||
|
v-model="formByProvider[prov.provider_id].base_url"
|
||||||
|
type="text"
|
||||||
|
:placeholder="providerDefaultBaseUrl(prov.provider_id) || '(default)'"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Model name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Model Name</label>
|
||||||
|
<input
|
||||||
|
v-model="formByProvider[prov.provider_id].model_name"
|
||||||
|
type="text"
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<!-- Context chars -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Context Characters</label>
|
||||||
|
<input
|
||||||
|
v-model.number="formByProvider[prov.provider_id].context_chars"
|
||||||
|
type="number"
|
||||||
|
:placeholder="String(providerDefaultContextChars(prov.provider_id))"
|
||||||
|
min="1000"
|
||||||
|
max="2000000"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex items-center gap-2 pt-1">
|
||||||
|
<!-- Set Active -->
|
||||||
|
<button
|
||||||
|
@click="saveSystemProvider(prov.provider_id, { activate: true })"
|
||||||
|
:disabled="savingProvider === prov.provider_id || prov.is_active"
|
||||||
|
class="text-xs bg-green-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Set Active
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="saveSystemProvider(prov.provider_id)"
|
||||||
|
:disabled="savingProvider === prov.provider_id"
|
||||||
|
class="text-xs bg-indigo-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span v-if="savingProvider === prov.provider_id" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
|
||||||
|
{{ savingProvider === prov.provider_id ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
>
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Test result badge -->
|
||||||
|
<span
|
||||||
|
v-if="testResults[prov.provider_id] === 'ok'"
|
||||||
|
class="text-xs bg-green-100 text-green-700 font-semibold px-2 py-1 rounded-full"
|
||||||
|
>OK</span>
|
||||||
|
<span
|
||||||
|
v-else-if="testResults[prov.provider_id] === 'failed'"
|
||||||
|
class="text-xs bg-red-100 text-red-700 font-semibold px-2 py-1 rounded-full"
|
||||||
|
>Failed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Per-user AI provider assignment (existing — DO NOT MODIFY) ─────── -->
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||||
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
|
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
|
||||||
@@ -76,6 +221,99 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import * as api from '../../api/client.js'
|
import * as api from '../../api/client.js'
|
||||||
|
import { getAiConfig, saveAiConfig, testAiConnection } from '../../api/client.js'
|
||||||
|
|
||||||
|
// ── Per-provider default lookup (mirrors backend PROVIDER_DEFAULTS) ───────────
|
||||||
|
|
||||||
|
const _PROVIDER_DEFAULTS = {
|
||||||
|
openai: { base_url: null, model: 'gpt-4o', context_chars: 120000 },
|
||||||
|
anthropic: { base_url: null, model: 'claude-sonnet-4-6', context_chars: 180000 },
|
||||||
|
gemini: { base_url: 'https://generativelanguage.googleapis.com/v1beta/openai/', model: 'gemini-2.0-flash', context_chars: 800000 },
|
||||||
|
groq: { base_url: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', context_chars: 128000 },
|
||||||
|
xai: { base_url: 'https://api.x.ai/v1', model: 'grok-3-mini', context_chars: 128000 },
|
||||||
|
deepseek: { base_url: 'https://api.deepseek.com', model: 'deepseek-chat', context_chars: 60000 },
|
||||||
|
openrouter: { base_url: 'https://openrouter.ai/api/v1', model: 'anthropic/claude-3.5-sonnet', context_chars: 180000 },
|
||||||
|
mistral: { base_url: 'https://api.mistral.ai/v1', model: 'mistral-large-latest', context_chars: 128000 },
|
||||||
|
ollama: { base_url: 'http://host.docker.internal:11434/v1', model: 'llama3.2', context_chars: 8000 },
|
||||||
|
lmstudio: { base_url: 'http://host.docker.internal:1234/v1', model: 'gemma-4-e4b-it', context_chars: 8000 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerDefaultBaseUrl(pid) { return _PROVIDER_DEFAULTS[pid]?.base_url || '' }
|
||||||
|
function providerDefaultModel(pid) { return _PROVIDER_DEFAULTS[pid]?.model || '' }
|
||||||
|
function providerDefaultContextChars(pid) { return _PROVIDER_DEFAULTS[pid]?.context_chars || 8000 }
|
||||||
|
|
||||||
|
// ── System provider state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const systemProviders = ref([])
|
||||||
|
const loadingSystem = ref(false)
|
||||||
|
const systemError = ref(null)
|
||||||
|
const openProviderId = ref(null)
|
||||||
|
const formByProvider = reactive({})
|
||||||
|
const testResults = reactive({})
|
||||||
|
const savingProvider = ref(null)
|
||||||
|
|
||||||
|
function toggleProvider(pid) {
|
||||||
|
openProviderId.value = openProviderId.value === pid ? null : pid
|
||||||
|
}
|
||||||
|
|
||||||
|
function _initForm(prov) {
|
||||||
|
formByProvider[prov.provider_id] = {
|
||||||
|
api_key: '', // write-only: never pre-filled from server
|
||||||
|
base_url: prov.base_url || '',
|
||||||
|
model_name: prov.model_name || '',
|
||||||
|
context_chars: prov.context_chars || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSystemProviders() {
|
||||||
|
loadingSystem.value = true
|
||||||
|
systemError.value = null
|
||||||
|
try {
|
||||||
|
const data = await getAiConfig()
|
||||||
|
systemProviders.value = data.providers || []
|
||||||
|
for (const prov of systemProviders.value) {
|
||||||
|
_initForm(prov)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
systemError.value = e.message || 'Failed to load AI provider config'
|
||||||
|
} finally {
|
||||||
|
loadingSystem.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSystemProvider(providerId, opts = {}) {
|
||||||
|
savingProvider.value = providerId
|
||||||
|
const form = formByProvider[providerId] || {}
|
||||||
|
const body = { provider_id: providerId }
|
||||||
|
|
||||||
|
// Only include fields the admin actually modified (api_key only if user typed something)
|
||||||
|
if (form.api_key !== '') body.api_key = form.api_key
|
||||||
|
if (form.base_url !== '') body.base_url = form.base_url
|
||||||
|
if (form.model_name !== '') body.model_name = form.model_name
|
||||||
|
if (form.context_chars && form.context_chars !== 0) body.context_chars = form.context_chars
|
||||||
|
if (opts.activate) body.is_active = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveAiConfig(body)
|
||||||
|
await loadSystemProviders()
|
||||||
|
} catch (e) {
|
||||||
|
systemError.value = e.message || 'Failed to save provider config'
|
||||||
|
} finally {
|
||||||
|
savingProvider.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTestConnection(providerId) {
|
||||||
|
try {
|
||||||
|
const r = await testAiConnection(providerId)
|
||||||
|
testResults[providerId] = r.ok ? 'ok' : 'failed'
|
||||||
|
} catch {
|
||||||
|
testResults[providerId] = 'failed'
|
||||||
|
}
|
||||||
|
setTimeout(() => { testResults[providerId] = null }, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-user assignment state (existing — DO NOT MODIFY) ──────────────────────
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -114,6 +352,9 @@ async function saveConfig(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Load system providers AND per-user list in parallel
|
||||||
|
loadSystemProviders()
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Vitest unit tests for the system AI provider client helpers.
|
||||||
|
*
|
||||||
|
* Tests verify that getAiConfig, saveAiConfig, and testAiConnection
|
||||||
|
* construct the correct fetch payloads (path, method, body, encoding).
|
||||||
|
*
|
||||||
|
* D-11 frontend coverage — CLAUDE.md mandate: every new function has at least one test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// ── Mock Pinia auth store (request() lazy-imports it) ────────────────────────
|
||||||
|
// The request() helper in client.js does:
|
||||||
|
// const { useAuthStore } = await import('../stores/auth.js')
|
||||||
|
// const authStore = useAuthStore()
|
||||||
|
// We stub the module so it returns a store with no accessToken,
|
||||||
|
// preventing Pinia initialization in unit tests.
|
||||||
|
vi.mock('../src/stores/auth.js', () => ({
|
||||||
|
useAuthStore: () => ({ accessToken: null }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Mock global fetch ─────────────────────────────────────────────────────────
|
||||||
|
let fetchMock
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Import helpers under test ─────────────────────────────────────────────────
|
||||||
|
import { getAiConfig, saveAiConfig, testAiConnection } from '../src/api/client.js'
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getAiConfig', () => {
|
||||||
|
it('issues GET /api/admin/ai-config', async () => {
|
||||||
|
await getAiConfig()
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce()
|
||||||
|
const [url, init] = fetchMock.mock.calls[0]
|
||||||
|
expect(url).toMatch(/\/api\/admin\/ai-config$/)
|
||||||
|
expect(init.method).toBe('GET')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saveAiConfig', () => {
|
||||||
|
it('issues PUT with JSON body containing provider_id and api_key', async () => {
|
||||||
|
const payload = { provider_id: 'openai', api_key: 'sk-x' }
|
||||||
|
await saveAiConfig(payload)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce()
|
||||||
|
const [url, init] = fetchMock.mock.calls[0]
|
||||||
|
expect(url).toMatch(/\/api\/admin\/ai-config$/)
|
||||||
|
expect(init.method).toBe('PUT')
|
||||||
|
expect(init.headers?.['Content-Type'] || init.headers?.get?.('Content-Type')).toBe('application/json')
|
||||||
|
expect(JSON.parse(init.body)).toEqual({ provider_id: 'openai', api_key: 'sk-x' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('testAiConnection', () => {
|
||||||
|
it('issues GET with URL-encoded provider_id', async () => {
|
||||||
|
// Provider ID with a space to verify encodeURIComponent is applied
|
||||||
|
await testAiConnection('open ai')
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce()
|
||||||
|
const [url, init] = fetchMock.mock.calls[0]
|
||||||
|
expect(url).toContain('/api/admin/ai-config/test-connection?provider_id=open%20ai')
|
||||||
|
expect(init.method).toBe('GET')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('issues GET with a plain provider_id that needs no encoding', async () => {
|
||||||
|
await testAiConnection('openai')
|
||||||
|
|
||||||
|
const [url] = fetchMock.mock.calls[0]
|
||||||
|
expect(url).toContain('/api/admin/ai-config/test-connection?provider_id=openai')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user