feat: models import (#164)

This commit is contained in:
Acbox Liu
2026-03-03 15:53:52 +08:00
committed by GitHub
parent 450cc30a9f
commit 5982bc6a42
18 changed files with 669 additions and 32 deletions
@@ -90,6 +90,53 @@
</FormControl>
</FormItem>
</FormField>
<Separator />
<FormField
v-slot="{ value, handleChange }"
name="auto_import"
>
<FormItem class="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div class="space-y-0.5">
<Label class="text-base">
{{ $t('provider.autoImport') }}
</Label>
<p class="text-[0.8rem] text-muted-foreground">
{{ $t('provider.autoImportHint') }}
</p>
</div>
<FormControl>
<Switch
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl>
</FormItem>
</FormField>
<FormField
v-if="form.values.auto_import"
v-slot="{ value, handleChange }"
name="client_type"
>
<FormItem>
<Label class="mb-2">
{{ $t('models.importClientType') }}
</Label>
<FormControl>
<SearchableSelectPopover
:model-value="value"
:options="CLIENT_TYPE_LIST"
:placeholder="$t('models.clientTypePlaceholder')"
@update:model-value="handleChange"
/>
</FormControl>
<p class="text-[0.8rem] text-muted-foreground">
{{ $t('models.importClientTypeHint') }}
</p>
</FormItem>
</FormField>
</div>
</template>
</FormDialogShell>
@@ -103,26 +150,52 @@ import {
FormControl,
FormItem,
Label,
Switch,
Separator,
} from '@memoh/ui'
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { useForm,Form,Field } from 'vee-validate'
import { useMutation, useQueryCache } from '@pinia/colada'
import { postProviders } from '@memoh/sdk'
import { postProviders, postProvidersByIdImportModels } from '@memoh/sdk'
import { useI18n } from 'vue-i18n'
import FormDialogShell from '@/components/form-dialog-shell/index.vue'
import { useDialogMutation } from '@/composables/useDialogMutation'
import SearchableSelectPopover from '@/components/searchable-select-popover/index.vue'
import { CLIENT_TYPE_LIST } from '@/constants/client-types'
import { toast } from 'vue-sonner'
const open = defineModel<boolean>('open')
const { t } = useI18n()
const { run } = useDialogMutation()
const queryCache = useQueryCache()
const { mutateAsync: createProviderMutation, isLoading } = useMutation({
mutation: async (data: Record<string, unknown>) => {
const { data: result } = await postProviders({ body: data as any, throwOnError: true })
const payload = {
...data,
metadata: { additionalProp1: {} },
}
const { data: result } = await postProviders({ body: payload as any, throwOnError: true })
if (data.auto_import && result?.id) {
try {
const { data: importResult } = await postProvidersByIdImportModels({
path: { id: result.id },
body: { client_type: data.client_type as string },
throwOnError: true,
})
if (importResult) {
toast.success(t('models.importSuccess', {
created: importResult.created,
skipped: importResult.skipped,
}))
}
}
catch (e) {
console.error('Auto import failed:', e)
toast.error(t('models.importFailed'))
}
}
return result
},
onSettled: () => queryCache.invalidateQueries({ key: ['providers'] }),
@@ -132,13 +205,16 @@ const providerSchema = toTypedSchema(z.object({
api_key: z.string().min(1),
base_url: z.string().min(1),
name: z.string().min(1),
metadata: z.object({
additionalProp1: z.object({}),
}),
auto_import: z.boolean().optional(),
client_type: z.string().optional(),
}))
const form = useForm({
validationSchema: providerSchema,
initialValues: {
auto_import: false,
client_type: 'openai-completions',
},
})
const createProvider = form.handleSubmit(async (value) => {
@@ -148,6 +224,7 @@ const createProvider = form.handleSubmit(async (value) => {
fallbackMessage: t('common.saveFailed'),
onSuccess: () => {
open.value = false
form.resetForm()
},
},
)
@@ -0,0 +1,102 @@
<template>
<FormDialogShell
v-model:open="open"
:title="$t('models.importModels')"
:cancel-text="$t('common.cancel')"
:submit-text="$t('common.import')"
:submit-disabled="!clientType"
:loading="isLoading"
@submit="handleImport"
>
<template #trigger>
<Button
variant="outline"
class="flex items-center gap-2"
>
<FontAwesomeIcon :icon="['fas', 'file-import']" />
{{ $t('models.importModels') }}
</Button>
</template>
<template #body>
<div class="flex flex-col gap-3 mt-4">
<Label class="mb-2">
{{ $t('models.importClientType') }}
</Label>
<SearchableSelectPopover
v-model="clientType"
:options="clientTypeOptions"
:placeholder="$t('models.clientTypePlaceholder')"
class="w-full"
/>
<p class="text-[0.8rem] text-muted-foreground">
{{ $t('models.importClientTypeHint') }}
</p>
</div>
</template>
</FormDialogShell>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMutation, useQueryCache } from '@pinia/colada'
import { postProvidersByIdImportModels } from '@memoh/sdk'
import { toast } from 'vue-sonner'
import { Button, Label } from '@memoh/ui'
import FormDialogShell from '@/components/form-dialog-shell/index.vue'
import SearchableSelectPopover from '@/components/searchable-select-popover/index.vue'
import { CLIENT_TYPE_LIST, CLIENT_TYPE_META } from '@/constants/client-types'
import { useDialogMutation } from '@/composables/useDialogMutation'
const props = defineProps<{
providerId: string
}>()
const open = ref(false)
const { t } = useI18n()
const { run } = useDialogMutation()
const queryCache = useQueryCache()
const clientType = ref('openai-completions')
const clientTypeOptions = computed(() =>
CLIENT_TYPE_LIST.map((ct) => ({
value: ct.value,
label: ct.label,
description: ct.hint,
keywords: [ct.label, ct.hint, CLIENT_TYPE_META[ct.value]?.value ?? ct.value],
})),
)
const { mutateAsync: importModelsMutation, isLoading } = useMutation({
mutation: async () => {
const { data } = await postProvidersByIdImportModels({
path: { id: props.providerId },
body: { client_type: clientType.value },
throwOnError: true,
})
return data
},
onSettled: () => {
queryCache.invalidateQueries({ key: ['provider-models'] })
},
})
async function handleImport() {
await run(
() => importModelsMutation(),
{
fallbackMessage: t('models.importFailed'),
onSuccess: (data) => {
if (data) {
toast.success(t('models.importSuccess', {
created: data.created,
skipped: data.skipped,
}))
}
open.value = false
},
},
)
}
</script>
@@ -42,8 +42,8 @@
>
</div>
<ScrollArea
class="max-h-64"
<div
class="max-h-64 overflow-y-auto"
role="listbox"
>
<div
@@ -112,7 +112,7 @@
</slot>
</button>
</div>
</ScrollArea>
</div>
</PopoverContent>
</Popover>
</template>
@@ -123,7 +123,6 @@ import {
PopoverTrigger,
PopoverContent,
Button,
ScrollArea,
} from '@memoh/ui'
import { computed, ref, watch } from 'vue'
+9 -2
View File
@@ -169,7 +169,12 @@
"testOk": "OK",
"testAuthError": "Auth Error",
"testError": "Error",
"testFailed": "Test failed"
"testFailed": "Test failed",
"importModels": "Import Models",
"importSuccess": "Successfully imported {created} models, skipped {skipped}",
"importFailed": "Failed to import models",
"importClientType": "Model Client Type",
"importClientTypeHint": "Set default client type for imported models"
},
"provider": {
"add": "Add Provider",
@@ -185,7 +190,9 @@
"testConnection": "Test Connection",
"reachable": "Reachable",
"unreachable": "Unreachable",
"testFailed": "Test failed"
"testFailed": "Test failed",
"autoImport": "Auto Import Models",
"autoImportHint": "Automatically fetch and import models from the provider after creation"
},
"searchProvider": {
"title": "Search Providers",
+9 -2
View File
@@ -165,7 +165,12 @@
"testOk": "正常",
"testAuthError": "认证失败",
"testError": "异常",
"testFailed": "测试失败"
"testFailed": "测试失败",
"importModels": "导入模型",
"importSuccess": "成功导入 {created} 个模型,跳过 {skipped} 个",
"importFailed": "导入模型失败",
"importClientType": "模型客户端类型",
"importClientTypeHint": "为导入的模型设置默认客户端类型"
},
"provider": {
"add": "添加服务商",
@@ -181,7 +186,9 @@
"testConnection": "测试连接",
"reachable": "可连接",
"unreachable": "不可连接",
"testFailed": "测试失败"
"testFailed": "测试失败",
"autoImport": "自动导入模型",
"autoImportHint": "创建后自动从服务商获取并导入模型"
},
"searchProvider": {
"title": "搜索提供方",
@@ -91,7 +91,7 @@ import {
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import { postModelsByIdTest } from '@memoh/sdk'
import type { ModelsGetResponse, ModelsTestResponse } from '@memoh/sdk'
import { ref, computed, onMounted } from 'vue'
import { ref, computed } from 'vue'
const props = defineProps<{
model: ModelsGetResponse
@@ -132,7 +132,4 @@ async function runTest() {
}
}
onMounted(() => {
runTest()
})
</script>
@@ -4,10 +4,13 @@
<h4 class="scroll-m-20 font-semibold tracking-tight">
{{ $t('models.title') }}
</h4>
<CreateModel
<div
v-if="providerId"
:id="providerId"
/>
class="flex items-center gap-2 ml-auto"
>
<ImportModelsDialog :provider-id="providerId" />
<CreateModel :id="providerId" />
</div>
</section>
<section
@@ -50,6 +53,7 @@ import {
EmptyTitle,
} from '@memoh/ui'
import CreateModel from '@/components/create-model/index.vue'
import ImportModelsDialog from '@/components/import-models-dialog/index.vue'
import ModelItem from './model-item.vue'
import type { ModelsGetResponse } from '@memoh/sdk'
@@ -73,9 +73,8 @@
:disabled="!props.provider?.id"
@click="runTest"
>
<Spinner v-if="testLoading" />
<FontAwesomeIcon
v-else
v-if="!testLoading"
:icon="['fas', 'rotate']"
/>
{{ $t('provider.testConnection') }}
@@ -197,9 +196,10 @@ async function runTest() {
}
}
watch(() => props.provider?.id, (newId) => {
if (newId) runTest()
}, { immediate: true })
watch(() => props.provider?.id, () => {
testResult.value = null
testError.value = ''
})
const providerSchema = toTypedSchema(z.object({
name: z.string().min(1),