diff --git a/app/components/ModelInfo.vue b/app/components/ModelInfo.vue index afa436e..40993ac 100644 --- a/app/components/ModelInfo.vue +++ b/app/components/ModelInfo.vue @@ -1,9 +1,8 @@ \ No newline at end of file + diff --git a/app/components/RowVirtualizerDynamic.vue b/app/components/RowVirtualizerDynamic.vue index 5994ba2..4cbe84a 100644 --- a/app/components/RowVirtualizerDynamic.vue +++ b/app/components/RowVirtualizerDynamic.vue @@ -10,11 +10,34 @@ const props = defineProps<{ prerender?: number; }>(); +const containerRef = ref(null); +const scrollMargin = ref(0); + +const updateScrollMargin = () => { + if (containerRef.value && props.scrollElement) { + const containerRect = containerRef.value.getBoundingClientRect(); + const scrollRect = props.scrollElement.getBoundingClientRect(); + scrollMargin.value = containerRect.top - scrollRect.top + props.scrollElement.scrollTop; + } +}; + +const resizeObserver = new ResizeObserver(updateScrollMargin); + +onMounted(() => { + resizeObserver.observe(containerRef.value!); + updateScrollMargin(); +}); + +onUnmounted(() => { + resizeObserver.disconnect(); +}); + const rowVirtualizer = useVirtualizer(computed(() => ({ count: props.items.length, getScrollElement: () => props.scrollElement, estimateSize: () => props.minItemSize, overscan: props.overscan, + scrollMargin: scrollMargin.value, getItemKey: (index: number) => props.keyField ? props.items[index]?.[props.keyField] || index : index, initialRect: { width: 0, @@ -31,8 +54,6 @@ const measureElement = (el: Element) => { } rowVirtualizer.value.measureElement(el) - - return undefined } watch(() => props.items, () => { @@ -41,17 +62,17 @@ watch(() => props.items, () => { \ No newline at end of file + diff --git a/app/components/Settings/AIServiceProvider.vue b/app/components/Settings/AIServiceProvider.vue index fcfeb88..4b367dd 100644 --- a/app/components/Settings/AIServiceProvider.vue +++ b/app/components/Settings/AIServiceProvider.vue @@ -178,6 +178,22 @@ const deleteModels = async () => { await Promise.all(provider.value.models.map(m => triplit.delete('models', m.id))); }; +const enableAllModels = async () => { + if (!provider.value) return; + + await Promise.all(provider.value.models.map(m => triplit.update('models', m.id, { + enabled: true + }))); +}; + +const disableAllModels = async () => { + if (!provider.value) return; + + await Promise.all(provider.value.models.map(m => triplit.update('models', m.id, { + enabled: false + }))); +}; + const enabledModels = computed(() => filterModels(provider.value?.models.filter(m => m.enabled === true) as Model[] || [], modelSearch.value) .sort(sortByReleaseDate) @@ -188,6 +204,164 @@ const disabledModels = computed(() => .sort(sortByReleaseDate) ) +// ============================================ +// Custom Model Add/Edit Panel +// ============================================ + +const showAddModelPanel = ref(false); +const editingModel = ref(null); + +interface ModelFormData { + name: string; + externalId: string; + contextWindow: string; + capabilities: string[]; + promptCost: string; + completionCost: string; + reasoning: boolean; + tools: boolean; + vision: boolean; +} + +const defaultFormData: ModelFormData = { + name: '', + externalId: '', + contextWindow: '', + capabilities: [], + promptCost: '', + completionCost: '', + reasoning: false, + tools: false, + vision: false, +}; + +const formData = ref({ ...defaultFormData }); + +const availableCapabilities = ['reasoning', 'tools', 'vision']; + +const toggleCapability = (cap: string) => { + const idx = formData.value.capabilities.indexOf(cap); + if (idx === -1) { + formData.value.capabilities.push(cap); + } else { + formData.value.capabilities.splice(idx, 1); + } + + if (cap === 'reasoning') { + formData.value.reasoning = formData.value.capabilities.includes('reasoning'); + } else if (cap === 'tools') { + formData.value.tools = formData.value.capabilities.includes('tools'); + } else if (cap === 'vision') { + formData.value.vision = formData.value.capabilities.includes('vision'); + } +}; + +const resetForm = () => { + formData.value = { ...defaultFormData }; + formData.value.capabilities = []; + editingModel.value = null; +}; + +const openAddPanel = () => { + resetForm(); + showAddModelPanel.value = true; +}; + +const openEditPanel = (model: Model) => { + editingModel.value = model; + formData.value = { + name: model.name || '', + externalId: model.externalId || '', + contextWindow: model.attributes?.contextWindow?.toString() || '', + capabilities: [...(model.attributes?.capabilities || [])], + promptCost: model.cost?.prompt || '', + completionCost: model.cost?.completion || '', + reasoning: model.attributes?.capabilities?.has('reasoning') || false, + tools: model.attributes?.capabilities?.has('tools') || false, + vision: model.attributes?.capabilities?.has('vision') || false, + }; + showAddModelPanel.value = true; +}; + +const { user } = useAuth(); + +// Preview model that reacts to form changes +const previewModel = computed(() => { + if (!formData.value.name || !formData.value.externalId) return null; + + const inputModalities = new Set(['text']); + const outputModalities = new Set(['text']); + + if (formData.value.capabilities.includes('vision')) { + inputModalities.add('image'); + } + + return { + userId: user.value?.id!, + id: editingModel.value?.id || 'preview', + providerId: provider.value?.id || '', + name: formData.value.name, + externalId: formData.value.externalId || 'model/id', + cost: { + prompt: formData.value.promptCost ? formatMoney(formData.value.promptCost) : undefined, + completion: formData.value.completionCost ? formatMoney(formData.value.completionCost) : undefined, + }, + attributes: { + inputModalities, + outputModalities, + capabilities: new Set(formData.value.capabilities.filter(c => c !== 'vision') as any), + contextWindow: formData.value.contextWindow ? parseInt(formData.value.contextWindow) : null, + }, + isCustom: true, + enabled: true, + provider: provider.value!, + } as Model; +}); + +const saveCustomModel = async () => { + if (!provider.value || !formData.value.name || !formData.value.externalId) return; + + const { id, ...previewModelWithoutId } = previewModel.value!; + if (editingModel.value) { + // Update existing model + await triplit.update('models', editingModel.value.id, previewModelWithoutId); + } else { + // Insert new custom model + await triplit.insert('models', previewModelWithoutId); + } + + showAddModelPanel.value = false; + resetForm(); +}; + +const cancelPanel = () => { + showAddModelPanel.value = false; + resetForm(); +}; + +const formatMoney = (value: string) => { + // ensure that there are two decimal places MINIMUM I WILL STRANGLE YOU SO HELP ME GOD + let [integerPart, fractionalPart] = value.split('.'); + + if (fractionalPart === undefined || fractionalPart === '') { + fractionalPart = '00'; + } + + if (fractionalPart.length === 1) { + fractionalPart += '0'; + } + + if (integerPart!.length === 0) { + integerPart = '0'; + } + + return `${integerPart}.${fractionalPart}`; +} + +const isFormValid = computed(() => { + return formData.value.name.trim() && formData.value.externalId.trim(); +}); + defineEmits(['navigate']); @@ -259,43 +433,191 @@ defineEmits(['navigate']); fetch models + +
+ + +
+ + + + + +
+
-
- - - No models found - -
+ +
+ +
+
+
+ +
+
+ {{ editingModel ? 'Edit Custom Model' : 'Add Custom Model' }} +
+ +
- -
- - Enabled - -
- - - -
+ +
+ +
+ + +
- - Disabled - -
- - - + +
+ + +
+ + +
+ + +
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Preview +
+
+ +
+
+ + +
+ + {{ + editingModel + ? 'Changes will be saved to existing model' + : 'Creates a new custom model' + }} + +
+ + +
+
+
+
- +
+ +
+
+ + + No models found + +
+ + +
+ + Enabled + +
+ + + +
+
+ +
+ + Disabled + +
+ + + +
+
+
+
diff --git a/app/components/Settings/ModelItem.vue b/app/components/Settings/ModelItem.vue index a6a5f2e..4a9abab 100644 --- a/app/components/Settings/ModelItem.vue +++ b/app/components/Settings/ModelItem.vue @@ -1,11 +1,21 @@ \ No newline at end of file +