Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions dev/en/model-selector.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ AI Foundation registers the FormKit input type `aiModelSelector`. Its saved valu

`modelType` accepts `language`, `embedding`, `rerank`, or `image-generation`.

## Capability filters
## Capability requirements

Common feature filter:
Common feature requirements:

```yaml
- $formkit: aiModelSelector
Expand All @@ -32,6 +32,10 @@ Common feature filter:
Other feature values include `streaming`, `vision`, `audio-input`, `structured-output`, and
`reasoning`.

`requiredFeatures` declares requirements rather than filtering: models missing the required
features are still listed and selectable, with a warning hint on the option. Use
`requiredCapabilities` when a hard filter is needed.

Fine-grained language capability:

```yaml
Expand Down Expand Up @@ -74,7 +78,7 @@ values do not count as support.
| `providerType` | Filter by provider type. |
| `enabled` | Filter by model enabled state. |
| `available` | Show only usable models; defaults to `true`. |
| `requiredFeatures` | Require all common feature flags. |
| `requiredFeatures` | Declare common feature requirements; non-matching models stay selectable with a warning. |
| `requiredCapabilities` | Require all fine-grained capability fields. |
| `placeholder` | Empty selection text. |
| `searchPlaceholder` | Search field placeholder. |
Expand Down
8 changes: 5 additions & 3 deletions dev/zh-CN/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -1087,15 +1087,17 @@ formSchema:
| `providerType` | 筛选指定供应方类型 |
| `enabled` | 按 `AiModel.spec.enabled` 筛选 |
| `available` | 只显示可用模型,默认 `true` |
| `requiredFeatures` | 只显示具备指定粗能力的模型 |
| `requiredFeatures` | 声明粗能力要求;不满足的模型显示警告但仍可选中 |
| `requiredCapabilities` | 只显示满足细粒度能力要求的模型 |
| `placeholder` | 未选择模型时的占位文本 |
| `searchPlaceholder` | 搜索框的占位文本 |
| `clearable` | 是否允许清空 |
| `fullWidth` | 是否占满容器宽度 |

`requiredFeatures` 适合筛选流式、图片识别、音频识别、工具调用等常用粗能力;`requiredCapabilities`
适合在确实需要时筛选媒体输入来源、媒体类型、图生图、单次生成数量等细粒度能力。没有匹配模型通常表示供应方未配置、模型被禁用,或模型能力不满足筛选条件。
`requiredFeatures` 适合声明流式、图片识别、音频识别、工具调用等常用粗能力要求(不满足的模型
仍会列出,仅在选项上显示警告);`requiredCapabilities`
适合在确实需要时硬性筛选媒体输入来源、媒体类型、图生图、单次生成数量等细粒度能力。
`requiredCapabilities` 没有匹配模型通常表示供应方未配置、模型被禁用,或模型能力不满足筛选条件。

`requiredCapabilities` 支持两个顶层域:`language` 和 `imageGeneration`。所有填写的条件都是
“全部满足”关系;未知能力不会被当作支持。
Expand Down
9 changes: 5 additions & 4 deletions dev/zh-CN/model-selector.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ formSchema:

`modelType` 支持 `language`、`embedding`、`rerank` 和 `image-generation`。

## 按常用能力筛选
## 按常用能力提醒

图片理解:

Expand All @@ -67,8 +67,9 @@ formSchema:
- audio-input
```

`requiredFeatures` 适合筛选流式、图片输入、音频输入、工具等常用粗粒度能力。没有匹配结果通常
表示模型未配置、已禁用或没有声明所需能力。
`requiredFeatures` 适合声明流式、图片输入、音频输入、工具等常用粗粒度能力要求。不满足要求的
模型仍会列出并可选中,仅在选项上显示警告提示(如"当前模型不支持工具调用,可能无法正常使用")。
如需硬性过滤,请使用 `requiredCapabilities`。

## 细粒度语言能力

Expand Down Expand Up @@ -143,7 +144,7 @@ AI Foundation 不会替模型下载 URL。要求 `url` 时,模型必须声明
| `providerType` | 按 Provider 类型筛选 |
| `enabled` | 按模型启用状态筛选 |
| `available` | 只显示可用模型,默认 `true` |
| `requiredFeatures` | 按常用粗粒度能力全部匹配 |
| `requiredFeatures` | 声明常用粗粒度能力要求,不满足的模型显示警告但仍可选中 |
| `requiredCapabilities` | 按细粒度 capability 全部匹配 |
| `placeholder` | 未选择时的占位文本 |
| `searchPlaceholder` | 搜索框占位文本 |
Expand Down
16 changes: 16 additions & 0 deletions ui/src/formkit/AiModelSelectOption.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
import type { ModelOption } from '@/api/generated'
import { capabilityUnavailableDetailsLabel } from '@/utils/capabilities'
import { SelectItem, SelectItemIndicator, SelectItemText } from 'reka-ui'
import { computed } from 'vue'
import MingcuteCheckLine from '~icons/mingcute/check-line'
import {
isModelOptionSelectable,
missingRequiredFeatureLabels,
modelDetailLabels,
modelOptionDisplayName,
modelOptionUnavailableReasonLabel,
Expand All @@ -15,11 +17,19 @@ import {
const props = defineProps<{
model: ModelOption
selected: boolean
requiredFeatures?: string[]
}>()

const emit = defineEmits<{
select: [model: ModelOption]
}>()

const missingFeatureLabels = computed(() => {
if (!isModelOptionSelectable(props.model)) {
return []
}
return missingRequiredFeatureLabels(props.model, props.requiredFeatures)
})
</script>

<template>
Expand Down Expand Up @@ -67,6 +77,12 @@ const emit = defineEmits<{
}}
</span>
</span>
<span
v-if="missingFeatureLabels.length"
class=":uno: mt-1 block text-[11px] text-orange-600 leading-4"
>
当前模型不支持{{ missingFeatureLabels.join('、') }},可能无法正常使用
</span>
</span>
</SelectItemText>

Expand Down
52 changes: 51 additions & 1 deletion ui/src/formkit/AiModelSelector.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ModelOption } from '@/api/generated'
import { useModelOptionsFetch } from '@/composables/use-model-options-fetch'
import { AI_FOUNDATION_ROUTE_NAMES } from '@/routes'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { createMemoryHistory, createRouter, RouterLink } from 'vue-router'
import AiModelSelector from './AiModelSelector.vue'

vi.mock('@/composables/use-model-options-fetch', () => ({
Expand Down Expand Up @@ -35,6 +37,7 @@ describe('AiModelSelector', () => {
providerName: 'openai',
providerDisplayName: 'OpenAI Production',
providerTypeDisplayName: 'OpenAI',
features: ['tool-call'],
}),
modelOption({
name: 'disabled-model',
Expand Down Expand Up @@ -141,11 +144,56 @@ describe('AiModelSelector', () => {
await wrapper.setProps({ modelValue: undefined })
expect(wrapper.get('[role="combobox"]').text()).toContain('请选择模型')
})

it('keeps models missing required features selectable with a warning', async () => {
const wrapper = mountSelector({ requiredFeatures: 'tool-call' })

expect(vi.mocked(useModelOptionsFetch).mock.calls[0]?.[0]).not.toHaveProperty(
'requiredFeatures',
)

await openSelector(wrapper)

expect(getOptions()).toHaveLength(3)
expect(getOption('DeepSeek Chat').textContent).toContain(
'当前模型不支持工具调用,可能无法正常使用',
)
expect(getOption('GPT-4o').textContent).not.toContain('当前模型不支持')
expect(getOption('Disabled Model').textContent).not.toContain('当前模型不支持')

await selectOption('DeepSeek Chat')

expect(wrapper.emitted('update:modelValue')).toContainEqual(['deepseek-chat'])
})

it('links to the model management page from the dropdown footer', async () => {
const wrapper = mountSelector()

await openSelector(wrapper)
const link = wrapper.findComponent(RouterLink)

expect(link.exists()).toBe(true)
expect(link.props('to')).toEqual({ name: AI_FOUNDATION_ROUTE_NAMES.MODELS })
expect(link.text()).toContain('管理模型')
})
})

function mountSelector(props: Record<string, unknown> = {}) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/ai-foundation/models',
name: AI_FOUNDATION_ROUTE_NAMES.MODELS,
component: { template: '<div />' },
},
],
})
const wrapper = mount(AiModelSelector, {
attachTo: document.body,
global: {
plugins: [router],
},
props: {
...props,
'onUpdate:modelValue': (value: string | undefined) => {
Expand Down Expand Up @@ -225,6 +273,7 @@ function modelOption({
providerDisplayName,
providerTypeDisplayName,
available = true,
features,
unavailableReason,
}: {
name: string
Expand All @@ -234,14 +283,15 @@ function modelOption({
providerDisplayName: string
providerTypeDisplayName: string
available?: boolean
features?: ModelOption['features']
unavailableReason?: ModelOption['unavailableReason']
}): ModelOption {
return {
name,
displayName,
modelId,
modelType: 'language',
features: [],
features: features ?? [],
available,
unavailableReason,
provider: {
Expand Down
18 changes: 16 additions & 2 deletions ui/src/formkit/AiModelSelector.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ModelOption } from '@/api/generated'
import { useModelOptionsFetch } from '@/composables/use-model-options-fetch'
import { AI_FOUNDATION_ROUTE_NAMES } from '@/routes'
import type { RequiredModelCapabilitiesValue } from '@/utils/capabilities'
import { groupModelOptionsByProvider } from '@/utils/model-options'
import { VLoading } from '@halo-dev/components'
Expand All @@ -19,9 +20,11 @@ import {
SelectViewport,
} from 'reka-ui'
import { computed, nextTick, shallowRef, useId, useTemplateRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import MingcuteCloseLine from '~icons/mingcute/close-line'
import MingcuteDownLine from '~icons/mingcute/down-line'
import MingcuteSearchLine from '~icons/mingcute/search-line'
import MingcuteSettings3Line from '~icons/mingcute/settings-3-line'
import RiBrainLine from '~icons/ri/brain-line'
import {
isModelOptionSelectable,
Expand Down Expand Up @@ -94,7 +97,6 @@ const { data: modelOptions, isLoading } = useModelOptionsFetch({
providerType,
enabled,
available,
requiredFeatures,
requiredCapabilities,
Comment thread
ruibaby marked this conversation as resolved.
})

Expand Down Expand Up @@ -136,7 +138,7 @@ const emptyText = computed(() => {
if (keyword.value) {
return '未找到匹配模型'
}
if (requiredFeatures.value?.length || requiredCapabilities.value) {
if (requiredCapabilities.value) {
return '没有满足能力要求的模型'
}
return '暂无匹配模型'
Expand Down Expand Up @@ -425,6 +427,7 @@ function handleSearchKeydown(event: KeyboardEvent) {
:key="model.name"
:model="model"
:selected="model.name === selectedValue"
:required-features="requiredFeatures"
@select="rememberSelection"
/>
</div>
Expand All @@ -437,6 +440,17 @@ function handleSearchKeydown(event: KeyboardEvent) {
<MingcuteDownLine class=":uno: size-4" aria-hidden="true" />
</SelectScrollDownButton>
</template>

<div class=":uno: border-t border-gray-100 p-1">
<RouterLink
:to="{ name: AI_FOUNDATION_ROUTE_NAMES.MODELS }"
class=":uno: flex select-none items-center gap-1.5 rounded-md px-2 py-1.5 text-[13px] text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
@click="isOpen = false"
Comment thread
ruibaby marked this conversation as resolved.
>
<MingcuteSettings3Line class=":uno: size-4 flex-none" aria-hidden="true" />
管理模型
</RouterLink>
</div>
</SelectContent>
</SelectPortal>
</SelectRoot>
Expand Down
14 changes: 14 additions & 0 deletions ui/src/formkit/ai-model-selector.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ModelOptionFeaturesEnum, type ModelOption } from '@/api/generated'
import { describe, expect, it } from 'vitest'
import {
missingRequiredFeatureLabels,
modelCapabilityLabels,
modelDetailLabels,
modelOptionDisplayName,
Expand Down Expand Up @@ -69,6 +70,19 @@ describe('ai model selector helpers', () => {
it('labels capability mismatch as unavailable reason', () => {
expect(modelOptionUnavailableReasonLabel('capability-unsupported')).toBe('能力不满足')
})

it('lists labels of required features the model does not provide', () => {
const visionOnly = model({ name: 'vision', features: [ModelOptionFeaturesEnum.Vision] })

expect(
missingRequiredFeatureLabels(visionOnly, [
ModelOptionFeaturesEnum.Vision,
ModelOptionFeaturesEnum.ToolCall,
]),
).toEqual(['工具调用'])
expect(missingRequiredFeatureLabels(visionOnly, [ModelOptionFeaturesEnum.Vision])).toEqual([])
expect(missingRequiredFeatureLabels(visionOnly, undefined)).toEqual([])
})
})

function model(overrides: Partial<ModelOption>): ModelOption {
Expand Down
8 changes: 8 additions & 0 deletions ui/src/formkit/ai-model-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export function isModelOptionSelectable(model: ModelOption) {
return model.available !== false
}

export function missingRequiredFeatureLabels(model: ModelOption, requiredFeatures?: string[]) {
if (!requiredFeatures?.length) {
return []
}
const features = new Set<string>(model.features ?? [])
return requiredFeatures.filter((feature) => !features.has(feature)).map(modelFeatureLabel)
}

export function modelOptionDisplayName(model: ModelOption) {
return model.displayName || model.modelId || model.name
}
Expand Down