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:
co-authored by
Claude Sonnet 4.6
parent
ac2dded35b
commit
3b11b9a596
+133
-16
@@ -152,6 +152,31 @@ class SystemAiConfigUpdate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectionRequest(BaseModel):
|
||||||
|
"""Request body for POST /api/admin/ai-config/test-connection.
|
||||||
|
|
||||||
|
Unsaved form values (api_key, base_url, model_name) override the DB row so
|
||||||
|
admins can verify credentials before saving. All override fields are optional;
|
||||||
|
omitting them falls back to whatever is stored in system_settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
api_key: Optional[str] = None # If non-empty, used instead of stored api_key_enc
|
||||||
|
base_url: Optional[str] = None # If non-None, overrides DB base_url
|
||||||
|
model_name: Optional[str] = None # If non-empty, overrides DB model_name
|
||||||
|
|
||||||
|
@field_validator("provider_id")
|
||||||
|
@classmethod
|
||||||
|
def provider_must_be_known(cls, v: str) -> str:
|
||||||
|
if v not in PROVIDER_DEFAULTS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class SystemTopicCreate(BaseModel):
|
class SystemTopicCreate(BaseModel):
|
||||||
"""Request model for admin system topic creation (D-09)."""
|
"""Request model for admin system topic creation (D-09)."""
|
||||||
|
|
||||||
@@ -628,32 +653,124 @@ async def create_system_topic(
|
|||||||
|
|
||||||
# ── System AI Provider Configuration (D-08, D-15) ────────────────────────────
|
# ── System AI Provider Configuration (D-08, D-15) ────────────────────────────
|
||||||
|
|
||||||
@router.get("/ai-config/test-connection")
|
@router.get("/ai-config/models")
|
||||||
async def test_ai_connection(
|
async def get_ai_config_models(
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Test connectivity for an AI provider configuration (D-08).
|
"""Return the list of model IDs available from a provider's API (D-08).
|
||||||
|
|
||||||
Loads the system_settings row for the given provider_id (ignoring is_active
|
Calls the provider's standard GET /models endpoint using the stored
|
||||||
so admins can test inactive configurations), builds a provider instance, and
|
config (base_url + api_key from system_settings). Always returns 200
|
||||||
calls health_check().
|
with {"models": [...]} — never 5xx on provider failure (returns empty list).
|
||||||
|
|
||||||
Returns {"ok": true/false, "provider_id": str} — never raises 5xx for
|
Security: requires get_current_admin; provider_id from query param only;
|
||||||
provider-side failures (surfaces as ok=False so the UI shows a clear status).
|
decrypted api_key never appears in the response.
|
||||||
|
|
||||||
Security: requires get_current_admin; provider_id sourced from query param,
|
|
||||||
provider instance built only from DB row (never from request body).
|
|
||||||
"""
|
"""
|
||||||
|
import httpx # noqa: PLC0415 — local import keeps admin.py startup fast
|
||||||
|
|
||||||
config = await load_provider_config_by_id(session, provider_id)
|
config = await load_provider_config_by_id(session, provider_id)
|
||||||
if config is None:
|
|
||||||
return {"ok": False, "provider_id": provider_id, "reason": "not_configured"}
|
# Resolve base_url: prefer DB row, fall back to PROVIDER_DEFAULTS
|
||||||
|
if config and config.base_url:
|
||||||
|
base_url = config.base_url.rstrip("/")
|
||||||
|
else:
|
||||||
|
base_url = (PROVIDER_DEFAULTS.get(provider_id, {}).get("base_url") or "").rstrip("/")
|
||||||
|
|
||||||
|
if not base_url:
|
||||||
|
return {"models": [], "provider_id": provider_id}
|
||||||
|
|
||||||
|
api_key = config.api_key if config else ""
|
||||||
|
|
||||||
|
# Build request headers — Anthropic uses x-api-key; all others use Bearer
|
||||||
|
if provider_id == "anthropic":
|
||||||
|
headers = {
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
}
|
||||||
|
models_url = "https://api.anthropic.com/v1/models"
|
||||||
|
else:
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||||
|
models_url = f"{base_url}/models"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
provider = get_provider(config)
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
await provider.health_check()
|
resp = await client.get(models_url, headers=headers)
|
||||||
return {"ok": True, "provider_id": provider_id}
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Standard OpenAI-compat shape: {"data": [{"id": "...", ...}, ...]}
|
||||||
|
# Anthropic shape: {"data": [{"id": "...", ...}, ...]}
|
||||||
|
# Ollama OpenAI-compat: same shape
|
||||||
|
raw_list = data.get("data") or data.get("models") or []
|
||||||
|
model_ids: list[str] = sorted(
|
||||||
|
{
|
||||||
|
item["id"] if isinstance(item, dict) else str(item)
|
||||||
|
for item in raw_list
|
||||||
|
if item
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"models": model_ids, "provider_id": provider_id}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"models": [], "provider_id": provider_id, "error": str(exc)[:120]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai-config/test-connection")
|
||||||
|
async def test_ai_connection(
|
||||||
|
body: TestConnectionRequest,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
_admin: User = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""Test connectivity for an AI provider, optionally with unsaved form values (D-08).
|
||||||
|
|
||||||
|
Loads the stored system_settings row for body.provider_id, then overlays any
|
||||||
|
non-empty values from the request body so admins can verify credentials before
|
||||||
|
saving them to the database.
|
||||||
|
|
||||||
|
Override priority (highest → lowest):
|
||||||
|
1. body.api_key / base_url / model_name (unsaved form values)
|
||||||
|
2. system_settings DB row (previously saved config)
|
||||||
|
3. PROVIDER_DEFAULTS (built-in fallback)
|
||||||
|
|
||||||
|
Returns {"ok": true/false, "provider_id": str} — never raises 5xx for
|
||||||
|
provider-side failures; surfaces as ok=False so the UI shows a clear status.
|
||||||
|
|
||||||
|
Security: requires get_current_admin; api_key from body is used only for the
|
||||||
|
in-flight health_check() call and is never stored or logged.
|
||||||
|
"""
|
||||||
|
provider_id = body.provider_id
|
||||||
|
stored = await load_provider_config_by_id(session, provider_id)
|
||||||
|
defaults = PROVIDER_DEFAULTS.get(provider_id, {})
|
||||||
|
|
||||||
|
# Resolve effective values: body overrides DB, DB overrides PROVIDER_DEFAULTS
|
||||||
|
effective_api_key = (
|
||||||
|
body.api_key
|
||||||
|
if body.api_key
|
||||||
|
else (stored.api_key if stored else "")
|
||||||
|
)
|
||||||
|
effective_base_url = (
|
||||||
|
body.base_url
|
||||||
|
if body.base_url is not None
|
||||||
|
else (stored.base_url if stored else defaults.get("base_url"))
|
||||||
|
)
|
||||||
|
effective_model = (
|
||||||
|
body.model_name
|
||||||
|
if body.model_name
|
||||||
|
else (stored.model if stored else defaults.get("model", ""))
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_config = ProviderConfig(
|
||||||
|
provider_id=provider_id,
|
||||||
|
api_key=effective_api_key,
|
||||||
|
base_url=effective_base_url,
|
||||||
|
model=effective_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = get_provider(effective_config)
|
||||||
|
ok = await provider.health_check()
|
||||||
|
return {"ok": ok, "provider_id": provider_id}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"ok": False, "provider_id": provider_id, "reason": "health_check_failed"}
|
return {"ok": False, "provider_id": provider_id, "reason": "health_check_failed"}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
return request(
|
||||||
'/api/admin/ai-config/test-connection?provider_id=' + encodeURIComponent(providerId),
|
'/api/admin/ai-config/models?provider_id=' + encodeURIComponent(providerId),
|
||||||
{ method: 'GET' }
|
{ method: 'GET' }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,14 +74,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Model name -->
|
<!-- Model name — searchable dropdown populated from provider's /models endpoint -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-600 mb-1">Model Name</label>
|
<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"
|
v-model="formByProvider[prov.provider_id].model_name"
|
||||||
type="text"
|
:provider-id="prov.provider_id"
|
||||||
:placeholder="providerDefaultModel(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>
|
</div>
|
||||||
|
|
||||||
@@ -122,10 +121,14 @@
|
|||||||
<!-- Test Connection -->
|
<!-- Test Connection -->
|
||||||
<button
|
<button
|
||||||
@click="runTestConnection(prov.provider_id)"
|
@click="runTestConnection(prov.provider_id)"
|
||||||
:disabled="savingProvider === prov.provider_id"
|
: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 transition-colors"
|
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>
|
</button>
|
||||||
|
|
||||||
<!-- Test result badge -->
|
<!-- Test result badge -->
|
||||||
@@ -222,6 +225,7 @@
|
|||||||
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'
|
import { getAiConfig, saveAiConfig, testAiConnection } from '../../api/client.js'
|
||||||
|
import SearchableModelSelect from '../ui/SearchableModelSelect.vue'
|
||||||
|
|
||||||
// ── Per-provider default lookup (mirrors backend PROVIDER_DEFAULTS) ───────────
|
// ── Per-provider default lookup (mirrors backend PROVIDER_DEFAULTS) ───────────
|
||||||
|
|
||||||
@@ -251,6 +255,7 @@ const openProviderId = ref(null)
|
|||||||
const formByProvider = reactive({})
|
const formByProvider = reactive({})
|
||||||
const testResults = reactive({})
|
const testResults = reactive({})
|
||||||
const savingProvider = ref(null)
|
const savingProvider = ref(null)
|
||||||
|
const testingProvider = ref(null)
|
||||||
|
|
||||||
function toggleProvider(pid) {
|
function toggleProvider(pid) {
|
||||||
openProviderId.value = openProviderId.value === pid ? null : pid
|
openProviderId.value = openProviderId.value === pid ? null : pid
|
||||||
@@ -304,11 +309,21 @@ async function saveSystemProvider(providerId, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runTestConnection(providerId) {
|
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 {
|
try {
|
||||||
const r = await testAiConnection(providerId)
|
const r = await testAiConnection(providerId, overrides)
|
||||||
testResults[providerId] = r.ok ? 'ok' : 'failed'
|
testResults[providerId] = r.ok ? 'ok' : 'failed'
|
||||||
} catch {
|
} catch {
|
||||||
testResults[providerId] = 'failed'
|
testResults[providerId] = 'failed'
|
||||||
|
} finally {
|
||||||
|
testingProvider.value = null
|
||||||
}
|
}
|
||||||
setTimeout(() => { testResults[providerId] = null }, 3000)
|
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="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>
|
<p class="text-xs text-gray-400 mt-0.5">{{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }}</p>
|
||||||
|
|
||||||
<!-- Topics -->
|
<!-- Topics (only when classified) -->
|
||||||
<div class="flex flex-wrap gap-1 mt-2">
|
<div v-if="doc.status === 'ready'" class="flex flex-wrap gap-1 mt-2">
|
||||||
<TopicBadge
|
<TopicBadge
|
||||||
v-for="topicName in doc.topics"
|
v-for="topicName in doc.topics"
|
||||||
:key="topicName"
|
:key="topicName"
|
||||||
@@ -27,6 +27,17 @@
|
|||||||
<span v-if="!doc.topics?.length" class="text-xs text-gray-300 italic">unclassified</span>
|
<span v-if="!doc.topics?.length" class="text-xs text-gray-300 italic">unclassified</span>
|
||||||
</div>
|
</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 -->
|
<!-- Shared indicator pill -->
|
||||||
<div v-if="doc.is_shared" class="mt-2">
|
<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>
|
<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>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { useFoldersStore } from '../stores/folders.js'
|
import { useFoldersStore } from '../stores/folders.js'
|
||||||
import { useDocumentsStore } from '../stores/documents.js'
|
import { useDocumentsStore } from '../stores/documents.js'
|
||||||
|
|||||||
Reference in New Issue
Block a user