- SearchableModelSelect: chevronDown + pencilEdit - SettingsCloudTab: warning (x2) + cloud icons - CloudProviderTreeItem: cloud icon - CloudFolderTreeItem: folder + fileDoc icons
275 lines
9.4 KiB
Vue
275 lines
9.4 KiB
Vue
<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"
|
|
>
|
|
<AppIcon
|
|
name="chevronDown"
|
|
:class="['w-4 h-4 transition-transform', isOpen ? 'rotate-180' : '']"
|
|
/>
|
|
</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"
|
|
>
|
|
<AppIcon name="pencilEdit" class="w-3.5 h-3.5 shrink-0" />
|
|
<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'
|
|
import AppIcon from './AppIcon.vue'
|
|
|
|
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>
|