Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/model/AddModelModal.vue
陈子默 1e6158be77 feat: 完善智能体图片聊天与会话恢复
- 增加私有图片上传、绑定、历史回显与生命周期清理

- 支持输入草稿恢复、图片交互和模型图片能力约束

- 修复旧脏会话幂等删除与前端会话恢复
2026-07-17 19:54:26 +08:00

532 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>