532 lines
14 KiB
Vue
532 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
|
||
|
||
import { computed, reactive, ref, watch } from 'vue';
|
||
|
||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||
import { IconifyIcon } from '@easyflow/icons';
|
||
|
||
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
|
||
|
||
import { api } from '#/api/request';
|
||
import { $t } from '#/locales';
|
||
import {
|
||
getDefaultModelAbility,
|
||
syncTagSelectedStatus as syncTagSelectedStatusUtil,
|
||
} from '#/views/ai/model/modelUtils/model-ability';
|
||
import {
|
||
generateFeaturesFromModelAbility,
|
||
resetModelAbility,
|
||
} from '#/views/ai/model/modelUtils/model-ability-utils';
|
||
|
||
interface FormData {
|
||
id?: string;
|
||
modelType: string;
|
||
title: string;
|
||
modelName: string;
|
||
groupName: string;
|
||
providerId: string;
|
||
invokeCode: string;
|
||
publishEnabled: boolean;
|
||
apiKey: string;
|
||
endpoint: string;
|
||
requestPath: string;
|
||
supportThinking: boolean;
|
||
supportTool: boolean;
|
||
supportImage: boolean;
|
||
supportAudio: boolean;
|
||
supportFree: boolean;
|
||
supportVideo: boolean;
|
||
supportImageB64Only: boolean;
|
||
supportToolMessage: boolean;
|
||
}
|
||
|
||
const props = defineProps({
|
||
providerId: {
|
||
type: String,
|
||
default: '',
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits(['reload']);
|
||
const selectedProviderId = ref<string>(props.providerId ?? '');
|
||
|
||
watch(
|
||
() => props.providerId,
|
||
(newVal) => {
|
||
if (newVal) {
|
||
selectedProviderId.value = newVal;
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
const formDataRef = ref();
|
||
const isAdd = ref(true);
|
||
const dialogVisible = ref(false);
|
||
const btnLoading = ref(false);
|
||
|
||
const formData = reactive<FormData>({
|
||
modelType: '',
|
||
title: '',
|
||
modelName: '',
|
||
groupName: '',
|
||
providerId: '',
|
||
invokeCode: '',
|
||
publishEnabled: false,
|
||
apiKey: '',
|
||
endpoint: '',
|
||
requestPath: '',
|
||
supportThinking: false,
|
||
supportTool: false,
|
||
supportImage: false,
|
||
supportAudio: false,
|
||
supportFree: false,
|
||
supportVideo: false,
|
||
supportImageB64Only: false,
|
||
supportToolMessage: true,
|
||
});
|
||
|
||
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
|
||
const visibleModelAbility = computed(() =>
|
||
modelAbility.value.filter(
|
||
(item) => item.field !== 'supportImageB64Only' || formData.supportImage,
|
||
),
|
||
);
|
||
type SelectableModelType = '' | 'embeddingModel' | 'rerankModel';
|
||
|
||
const selectedModelType = ref<SelectableModelType>('');
|
||
const modelTypeAbilityOptions = [
|
||
{
|
||
label: $t('llmProvider.embeddingModel'),
|
||
value: 'embeddingModel',
|
||
},
|
||
{
|
||
label: $t('llmProvider.rerankModel'),
|
||
value: 'rerankModel',
|
||
},
|
||
] as const;
|
||
const hasSpecialModelType = computed(() => Boolean(selectedModelType.value));
|
||
const abilityIconMap: Record<string, string> = {
|
||
embeddingModel: 'svg:knowledge',
|
||
rerankModel: 'svg:data-center',
|
||
thinking: 'svg:llm',
|
||
tool: 'svg:wrench',
|
||
video: 'mdi:video-outline',
|
||
image: 'mdi:image-outline',
|
||
audio: 'mdi:microphone-outline',
|
||
imageB64: 'mdi:file-image-outline',
|
||
toolMessage: 'mdi:hammer',
|
||
};
|
||
|
||
const syncTagSelectedStatus = () => {
|
||
syncTagSelectedStatusUtil(modelAbility.value, formData);
|
||
};
|
||
|
||
const resetAbilitySelection = () => {
|
||
resetModelAbility(modelAbility.value);
|
||
syncTagSelectedStatus();
|
||
};
|
||
|
||
const handleTagClick = (item: ModelAbilityItem) => {
|
||
if (hasSpecialModelType.value) {
|
||
return;
|
||
}
|
||
item.selected = !item.selected;
|
||
formData[item.field] = item.selected;
|
||
if (item.field === 'supportImage' && !item.selected) {
|
||
formData.supportImageB64Only = false;
|
||
const base64Ability = modelAbility.value.find(
|
||
(ability) => ability.field === 'supportImageB64Only',
|
||
);
|
||
if (base64Ability) base64Ability.selected = false;
|
||
}
|
||
};
|
||
|
||
const handleModelTypeChipClick = (
|
||
modelType: Exclude<SelectableModelType, ''>,
|
||
) => {
|
||
const nextType = selectedModelType.value === modelType ? '' : modelType;
|
||
selectedModelType.value = nextType;
|
||
if (nextType) {
|
||
resetAbilitySelection();
|
||
}
|
||
};
|
||
|
||
const isAbilityChipDisabled = () => hasSpecialModelType.value;
|
||
const getAbilityIcon = (value: string) => abilityIconMap[value] || 'svg:llm';
|
||
|
||
const resolveModelType = (): FormData['modelType'] => {
|
||
return selectedModelType.value || 'chatModel';
|
||
};
|
||
|
||
const normalizeSelectableModelType = (
|
||
modelType?: string,
|
||
): SelectableModelType => {
|
||
return modelType === 'embeddingModel' || modelType === 'rerankModel'
|
||
? modelType
|
||
: '';
|
||
};
|
||
|
||
const resetFormData = () => {
|
||
Object.assign(formData, {
|
||
id: '',
|
||
modelType: '',
|
||
title: '',
|
||
modelName: '',
|
||
groupName: '',
|
||
providerId: '',
|
||
invokeCode: '',
|
||
publishEnabled: false,
|
||
apiKey: '',
|
||
endpoint: '',
|
||
requestPath: '',
|
||
supportThinking: false,
|
||
supportTool: false,
|
||
supportAudio: false,
|
||
supportVideo: false,
|
||
supportImage: false,
|
||
supportImageB64Only: false,
|
||
supportFree: false,
|
||
supportToolMessage: true,
|
||
});
|
||
};
|
||
|
||
defineExpose({
|
||
openAddDialog(modelType?: string) {
|
||
isAdd.value = true;
|
||
formDataRef.value?.resetFields();
|
||
resetFormData();
|
||
selectedModelType.value = normalizeSelectableModelType(modelType);
|
||
if (selectedModelType.value) {
|
||
resetAbilitySelection();
|
||
} else {
|
||
syncTagSelectedStatus();
|
||
}
|
||
dialogVisible.value = true;
|
||
},
|
||
|
||
openEditDialog(item: any) {
|
||
dialogVisible.value = true;
|
||
isAdd.value = false;
|
||
resetFormData();
|
||
Object.assign(formData, {
|
||
id: item.id,
|
||
modelType: item.modelType || '',
|
||
title: item.title || '',
|
||
modelName: item.modelName || '',
|
||
groupName: item.groupName || '',
|
||
providerId: item.providerId || '',
|
||
invokeCode: item.invokeCode || '',
|
||
publishEnabled: item.publishEnabled || false,
|
||
endpoint: item.endpoint || '',
|
||
requestPath: item.requestPath || '',
|
||
apiKey: item.apiKey || '',
|
||
supportThinking: item.supportThinking || false,
|
||
supportAudio: item.supportAudio || false,
|
||
supportImage: item.supportImage || false,
|
||
supportImageB64Only: item.supportImageB64Only || false,
|
||
supportVideo: item.supportVideo || false,
|
||
supportTool: item.supportTool || false,
|
||
supportFree: item.supportFree || false,
|
||
supportToolMessage:
|
||
item.supportToolMessage === undefined ? true : item.supportToolMessage,
|
||
});
|
||
selectedModelType.value = normalizeSelectableModelType(item.modelType);
|
||
if (selectedModelType.value) {
|
||
resetAbilitySelection();
|
||
} else {
|
||
syncTagSelectedStatus();
|
||
}
|
||
},
|
||
});
|
||
|
||
const closeDialog = () => {
|
||
dialogVisible.value = false;
|
||
};
|
||
|
||
const rules = {
|
||
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
|
||
modelName: [
|
||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||
],
|
||
groupName: [
|
||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||
],
|
||
};
|
||
|
||
const save = async () => {
|
||
btnLoading.value = true;
|
||
const modelType = resolveModelType();
|
||
const features = generateFeaturesFromModelAbility(modelAbility.value);
|
||
|
||
if (modelType !== 'chatModel') {
|
||
for (const key of Object.keys(features) as Array<keyof typeof features>) {
|
||
features[key] = false;
|
||
}
|
||
}
|
||
|
||
try {
|
||
await formDataRef.value.validate();
|
||
const submitData = {
|
||
...formData,
|
||
...features,
|
||
modelType,
|
||
providerId: isAdd.value ? selectedProviderId.value : formData.providerId,
|
||
};
|
||
|
||
const url = isAdd.value ? '/api/v1/model/save' : '/api/v1/model/update';
|
||
const res = await api.post(url, submitData);
|
||
|
||
if (res.errorCode === 0) {
|
||
ElMessage.success(res.message);
|
||
emit('reload');
|
||
closeDialog();
|
||
} else {
|
||
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||
}
|
||
} catch (error) {
|
||
if (!(error as any)?.fields) {
|
||
ElMessage.error($t('ui.actionMessage.operationFailed'));
|
||
}
|
||
} finally {
|
||
btnLoading.value = false;
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<EasyFlowFormModal
|
||
v-model:open="dialogVisible"
|
||
:centered="true"
|
||
:closable="!btnLoading"
|
||
:title="isAdd ? '添加模型' : '编辑模型'"
|
||
:before-close="closeDialog"
|
||
width="640"
|
||
:confirm-loading="btnLoading"
|
||
:confirm-text="$t('button.save')"
|
||
:submitting="btnLoading"
|
||
@confirm="save"
|
||
>
|
||
<div class="model-modal">
|
||
<ElForm
|
||
ref="formDataRef"
|
||
:model="formData"
|
||
status-icon
|
||
:rules="rules"
|
||
label-position="top"
|
||
class="model-modal__form"
|
||
>
|
||
<div class="model-modal__section">
|
||
<ElFormItem prop="title" :label="$t('llm.title')">
|
||
<ElInput
|
||
v-model.trim="formData.title"
|
||
placeholder="例如:生产主模型"
|
||
/>
|
||
</ElFormItem>
|
||
<ElFormItem prop="modelName" :label="$t('llm.llmModel')">
|
||
<ElInput
|
||
v-model.trim="formData.modelName"
|
||
placeholder="例如:gpt-4.1 / glm-4.5 / qwen3:8b"
|
||
/>
|
||
</ElFormItem>
|
||
<ElFormItem prop="groupName" :label="$t('llm.groupName')">
|
||
<ElInput
|
||
v-model.trim="formData.groupName"
|
||
placeholder="例如:默认组"
|
||
/>
|
||
</ElFormItem>
|
||
|
||
<ElFormItem
|
||
:label="$t('llm.ability')"
|
||
class="model-modal__ability-item"
|
||
>
|
||
<div class="model-modal__ability-panel">
|
||
<div class="model-modal__ability-toolbar">
|
||
<button
|
||
v-for="item in modelTypeAbilityOptions"
|
||
:key="item.value"
|
||
type="button"
|
||
class="model-modal__ability-chip"
|
||
:class="[
|
||
`is-tone-${item.value}`,
|
||
{ 'is-active': selectedModelType === item.value },
|
||
]"
|
||
@click="handleModelTypeChipClick(item.value)"
|
||
>
|
||
<IconifyIcon
|
||
:icon="getAbilityIcon(item.value)"
|
||
class="model-modal__ability-icon"
|
||
/>
|
||
{{ item.label }}
|
||
</button>
|
||
<span
|
||
class="model-modal__ability-separator"
|
||
aria-hidden="true"
|
||
></span>
|
||
<button
|
||
v-for="item in visibleModelAbility"
|
||
:key="item.value"
|
||
type="button"
|
||
class="model-modal__ability-chip"
|
||
:class="{
|
||
'is-active': item.selected,
|
||
'is-disabled': isAbilityChipDisabled(),
|
||
[`is-tone-${item.value}`]: true,
|
||
}"
|
||
:disabled="isAbilityChipDisabled()"
|
||
@click="handleTagClick(item)"
|
||
>
|
||
<IconifyIcon
|
||
:icon="getAbilityIcon(item.value)"
|
||
class="model-modal__ability-icon"
|
||
/>
|
||
{{ item.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</ElFormItem>
|
||
</div>
|
||
</ElForm>
|
||
</div>
|
||
</EasyFlowFormModal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.model-modal {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.model-modal__section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 16px 18px;
|
||
background: hsl(var(--surface-panel) / 96%);
|
||
border: 1px solid hsl(var(--divider-faint) / 42%);
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.model-modal__form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.model-modal__ability-item {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.model-modal__ability-panel {
|
||
padding: 2px;
|
||
overflow: hidden;
|
||
border-radius: 18px;
|
||
}
|
||
|
||
.model-modal__ability-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.model-modal__ability-separator {
|
||
align-self: stretch;
|
||
width: 1px;
|
||
min-height: 28px;
|
||
background: hsl(var(--divider-faint) / 62%);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.model-modal__ability-chip {
|
||
display: inline-flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 40px;
|
||
padding: 9px 18px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
color: hsl(var(--text-muted));
|
||
cursor: pointer;
|
||
background: hsl(var(--surface-contrast-soft) / 86%);
|
||
border: 1px solid transparent;
|
||
border-radius: 999px;
|
||
transition:
|
||
transform 0.2s ease,
|
||
border-color 0.2s ease,
|
||
color 0.2s ease,
|
||
background 0.2s ease,
|
||
box-shadow 0.2s ease;
|
||
}
|
||
|
||
.model-modal__ability-chip:hover:not(:disabled),
|
||
.model-modal__ability-chip:focus-visible:not(:disabled) {
|
||
color: hsl(var(--text-strong));
|
||
box-shadow: 0 10px 18px -14px hsl(var(--foreground) / 28%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.model-modal__ability-chip.is-active {
|
||
color: hsl(var(--text-strong));
|
||
background: hsl(var(--primary) / 10%);
|
||
border-color: hsl(var(--primary) / 18%);
|
||
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 14%);
|
||
}
|
||
|
||
.model-modal__ability-chip.is-disabled {
|
||
cursor: not-allowed;
|
||
box-shadow: none;
|
||
opacity: 0.56;
|
||
transform: none;
|
||
}
|
||
|
||
.model-modal__ability-icon {
|
||
font-size: 15px;
|
||
opacity: 0.88;
|
||
}
|
||
|
||
.model-modal__ability-chip.is-active.is-tone-embeddingModel,
|
||
.model-modal__ability-chip.is-active.is-tone-thinking,
|
||
.model-modal__ability-chip.is-active.is-tone-toolMessage {
|
||
color: hsl(var(--primary));
|
||
background: hsl(var(--primary) / 10%);
|
||
border-color: hsl(var(--primary) / 18%);
|
||
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 14%);
|
||
}
|
||
|
||
.model-modal__ability-chip.is-active.is-tone-rerankModel,
|
||
.model-modal__ability-chip.is-active.is-tone-tool {
|
||
color: hsl(var(--warning));
|
||
background: hsl(var(--warning) / 12%);
|
||
border-color: hsl(var(--warning) / 18%);
|
||
box-shadow: inset 0 0 0 1px hsl(var(--warning) / 14%);
|
||
}
|
||
|
||
.model-modal__ability-chip.is-active.is-tone-image,
|
||
.model-modal__ability-chip.is-active.is-tone-imageB64 {
|
||
color: hsl(var(--success));
|
||
background: hsl(var(--success) / 12%);
|
||
border-color: hsl(var(--success) / 18%);
|
||
box-shadow: inset 0 0 0 1px hsl(var(--success) / 14%);
|
||
}
|
||
|
||
.model-modal__ability-chip.is-active.is-tone-audio,
|
||
.model-modal__ability-chip.is-active.is-tone-video,
|
||
.model-modal__ability-chip.is-active.is-tone-free {
|
||
color: hsl(var(--danger));
|
||
background: hsl(var(--danger) / 10%);
|
||
border-color: hsl(var(--danger) / 16%);
|
||
box-shadow: inset 0 0 0 1px hsl(var(--danger) / 12%);
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.model-modal__ability-separator {
|
||
display: none;
|
||
}
|
||
}
|
||
</style>
|