Files
Memoh/apps/web/src/components/create-model/index.vue
T
2026-04-22 00:10:36 +08:00

359 lines
11 KiB
Vue

<template>
<section class="ml-auto">
<FormDialogShell
v-model:open="open"
:title="title === 'edit' ? $t('models.editModel') : $t('models.addModel')"
:cancel-text="$t('common.cancel')"
:submit-text="title === 'edit' ? $t('common.save') : $t('models.addModel')"
:submit-disabled="!canSubmit"
:loading="isLoading"
@submit="addModel"
>
<template #trigger>
<Button variant="default">
{{ $t('models.addModel') }}
</Button>
</template>
<template #body>
<div class="flex flex-col gap-3 mt-4">
<!-- Type -->
<FormField
v-slot="{ componentField }"
name="type"
>
<FormItem>
<Label class="mb-2">
{{ $t('common.type') }}
</Label>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger
class="w-full"
:aria-label="$t('common.type')"
>
<SelectValue :placeholder="$t('common.typePlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="chat">
Chat
</SelectItem>
<SelectItem value="embedding">
Embedding
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
</FormField>
<!-- Model -->
<FormField
v-slot="{ componentField }"
name="model_id"
>
<FormItem>
<Label class="mb-2">
{{ $t('models.model') }}
</Label>
<FormControl>
<Input
type="text"
:placeholder="$t('models.modelPlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<!-- Display Name -->
<FormField
name="name"
>
<FormItem>
<Label class="mb-2">
{{ $t('models.displayName') }}
<span class="text-muted-foreground text-xs ml-1">({{ $t('common.optional') }})</span>
</Label>
<FormControl>
<Input
type="text"
:placeholder="$t('models.displayNamePlaceholder')"
:model-value="form.values.name ?? ''"
@input="onNameInput"
/>
</FormControl>
</FormItem>
</FormField>
<!-- Dimensions (embedding only) -->
<FormField
v-if="selectedType === 'embedding'"
v-slot="{ componentField }"
name="dimensions"
>
<FormItem>
<Label class="mb-2">
{{ $t('models.dimensions') }}
</Label>
<FormControl>
<Input
type="number"
:placeholder="$t('models.dimensionsPlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<!-- Compatibilities (chat only) -->
<div v-if="selectedType === 'chat'">
<Label class="mb-4">
{{ $t('models.compatibilities') }}
</Label>
<div class="flex flex-wrap gap-3 mt-2">
<label
v-for="opt in COMPATIBILITY_OPTIONS"
:key="opt.value"
class="flex items-center gap-1.5 text-xs"
>
<Checkbox
:model-value="selectedCompat.includes(opt.value)"
@update:model-value="(val: boolean) => toggleCompat(opt.value, val)"
/>
{{ $t(`models.compatibility.${opt.value}`) }}
</label>
</div>
</div>
<!-- Context Window (optional) -->
<FormField
v-if="selectedType === 'chat'"
v-slot="{ componentField }"
name="context_window"
>
<FormItem>
<Label class="mb-2">
{{ $t('models.contextWindow') }}
<span class="text-muted-foreground text-xs ml-1">({{ $t('common.optional') }})</span>
</Label>
<FormControl>
<Input
type="number"
:placeholder="$t('models.contextWindowPlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
</div>
</template>
</FormDialogShell>
</section>
</template>
<script setup lang="ts">
import {
Input,
Button,
FormField,
FormControl,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
FormItem,
Checkbox,
Label,
} from '@memohai/ui'
import { useForm } from 'vee-validate'
import { inject, computed, watch, nextTick, type Ref, ref } from 'vue'
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { useMutation, useQueryCache } from '@pinia/colada'
import { postModels, putModelsById, putModelsModelByModelId } from '@memohai/sdk'
import type { ModelsGetResponse, ModelsAddRequest, ModelsUpdateRequest } from '@memohai/sdk'
import { useI18n } from 'vue-i18n'
import { COMPATIBILITY_OPTIONS } from '@/constants/compatibilities'
import FormDialogShell from '@/components/form-dialog-shell/index.vue'
import { useDialogMutation } from '@/composables/useDialogMutation'
const selectedCompat = ref<string[]>([])
const { t } = useI18n()
const { run } = useDialogMutation()
const formSchema = toTypedSchema(z.object({
type: z.string().min(1),
model_id: z.string().min(1),
name: z.string().optional(),
dimensions: z.coerce.number().min(1).optional(),
context_window: z.coerce.number().min(1).optional(),
}))
const form = useForm({
validationSchema: formSchema,
initialValues: {
type: 'chat',
},
})
const selectedType = computed(() => form.values.type || 'chat')
const open = inject<Ref<boolean>>('openModel', ref(false))
const title = inject<Ref<'edit' | 'title'>>('openModelTitle', ref('title'))
const editInfo = inject<Ref<ModelsGetResponse | null>>('openModelState', ref(null))
const canSubmit = computed(() => {
if (title.value === 'edit') return true
const { type, model_id } = form.values
if (!type || !model_id) return false
return true
})
function toggleCompat(cap: string, checked: boolean) {
if (checked) {
selectedCompat.value = [...selectedCompat.value, cap]
} else {
selectedCompat.value = selectedCompat.value.filter(c => c !== cap)
}
}
const userEditedName = ref(false)
watch(
() => form.values.model_id,
(newModelId) => {
if (!userEditedName.value && newModelId !== undefined) {
form.setFieldValue('name', newModelId)
}
},
)
function onNameInput(e: Event) {
userEditedName.value = true
form.setFieldValue('name', (e.target as HTMLInputElement).value)
}
const { id } = defineProps<{ id: string }>()
const queryCache = useQueryCache()
const { mutateAsync: createModel, isLoading: createLoading } = useMutation({
mutation: async (data: Record<string, unknown>) => {
const { data: result } = await postModels({ body: data as ModelsAddRequest, throwOnError: true })
return result
},
onSettled: () => queryCache.invalidateQueries({ key: ['provider-models'] }),
})
const { mutateAsync: updateModel, isLoading: updateLoading } = useMutation({
mutation: async ({ id, data }: { id: string; data: Record<string, unknown> }) => {
const { data: result } = await putModelsById({
path: { id },
body: data as ModelsUpdateRequest,
throwOnError: true,
})
return result
},
onSettled: () => queryCache.invalidateQueries({ key: ['provider-models'] }),
})
const { mutateAsync: updateModelByLegacyModelID, isLoading: updateLegacyLoading } = useMutation({
mutation: async ({ modelId, data }: { modelId: string; data: Record<string, unknown> }) => {
const { data: result } = await putModelsModelByModelId({
path: { modelId },
body: data as ModelsUpdateRequest,
throwOnError: true,
})
return result
},
onSettled: () => queryCache.invalidateQueries({ key: ['provider-models'] }),
})
const isLoading = computed(() => createLoading.value || updateLoading.value || updateLegacyLoading.value)
async function addModel() {
const isEdit = title.value === 'edit' && !!editInfo?.value
const fallback = editInfo?.value
const type = form.values.type || (isEdit ? fallback!.type : 'chat')
const model_id = form.values.model_id || (isEdit ? fallback!.model_id : '')
const name = form.values.name ?? (isEdit ? fallback!.name : '')
if (!type || !model_id) return
const config: Record<string, unknown> = {}
if (type === 'embedding') {
const dim = form.values.dimensions ?? (isEdit ? fallback!.config?.dimensions : undefined)
if (dim) config.dimensions = dim
}
if (type === 'chat') {
config.compatibilities = selectedCompat.value
const ctxWin = form.values.context_window ?? (isEdit ? fallback!.config?.context_window : undefined)
if (ctxWin) config.context_window = ctxWin
}
const payload: Record<string, unknown> = {
type,
model_id,
provider_id: id,
config,
}
if (name) {
payload.name = name
}
await run(
() => {
if (isEdit) {
const modelUUID = fallback?.id
if (modelUUID) {
return updateModel({ id: modelUUID, data: payload as ModelsUpdateRequest })
}
return updateModelByLegacyModelID({ modelId: fallback!.model_id, data: payload as ModelsUpdateRequest })
}
return createModel(payload)
},
{
fallbackMessage: t('common.saveFailed'),
onSuccess: () => {
open.value = false
},
},
)
}
watch(open, async () => {
if (!open.value) {
title.value = 'title'
editInfo.value = null
return
}
await nextTick()
if (editInfo?.value) {
const { type, model_id, name, config } = editInfo.value
form.resetForm({
values: {
type: type || 'chat',
model_id,
name,
dimensions: config?.dimensions,
context_window: config?.context_window,
},
})
selectedCompat.value = config?.compatibilities ?? []
userEditedName.value = !!(name && name !== model_id)
} else {
form.resetForm({ values: { type: 'chat', model_id: '', name: '', dimensions: undefined, context_window: undefined } })
selectedCompat.value = []
userEditedName.value = false
}
}, {
immediate: true,
})
</script>