Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/model/ActiveModelWorkspace.vue
陈子默 791649c7d5 feat: 增强智能体模型调用兼容能力
- 增加模型流式开关和 HTTP 传输策略配置

- 使用 AgentScope 执行基础连接、流式与 VLM 双阶段验证

- 固定多模态校验图片并统一验证状态展示
2026-07-17 19:57:06 +08:00

784 lines
19 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 { llmType } from '#/api';
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
import type { VerifyButtonStatus } from '#/views/ai/model/modelUtils/model-verification';
import { computed, onMounted, reactive, ref } from 'vue';
import {
CircleCheck,
CircleClose,
Delete,
Edit,
Loading,
Select,
Warning,
} from '@element-plus/icons-vue';
import {
ElButton,
ElEmpty,
ElIcon,
ElInput,
ElMessage,
ElMessageBox,
ElOption,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import { deleteLlm, getModelList, verifyModelConfig } from '#/api/ai/llm';
import { $t } from '#/locales';
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
import { getDefaultModelAbility } from '#/views/ai/model/modelUtils/model-ability';
import { mapLlmToModelAbility } from '#/views/ai/model/modelUtils/model-ability-utils';
import {
getVerifyButtonText as getStatusButtonText,
resolveModelVerificationFeedback,
} from '#/views/ai/model/modelUtils/model-verification';
interface ProviderOption {
id: string;
providerName: string;
}
interface FilterState {
keyword: string;
modelType: string;
providerId: string;
}
interface BatchDeleteResult {
failed: Array<{ id: string; message: string }>;
successIds: string[];
}
const props = defineProps<{
providers: ProviderOption[];
}>();
const emit = defineEmits<{
(e: 'createModel', modelType?: string): void;
(e: 'editModel', id: string): void;
(e: 'refreshProviderStats'): void;
}>();
const isLoading = ref(false);
const isActionLoading = ref(false);
const modelRows = ref<llmType[]>([]);
const selectedRows = ref<llmType[]>([]);
const lastErrorMessage = ref('');
const verifyStatusMap = ref<Record<string, VerifyButtonStatus>>({});
const filterState = reactive<FilterState>({
keyword: '',
modelType: '',
providerId: '',
});
const modelTypeOptions = [
{
label: $t('llmProvider.chatModel'),
value: 'chatModel',
},
{
label: $t('llmProvider.embeddingModel'),
value: 'embeddingModel',
},
{
label: $t('llmProvider.rerankModel'),
value: 'rerankModel',
},
];
const modelTypeLabelMap: Record<string, string> = {
chatModel: $t('llmProvider.chatModel'),
embeddingModel: $t('llmProvider.embeddingModel'),
rerankModel: $t('llmProvider.rerankModel'),
};
const getProviderName = (row: llmType) =>
row.modelProvider?.providerName || row.aiLlmProvider?.providerName || '-';
const getProviderType = (row: llmType) =>
row.modelProvider?.providerType || row.aiLlmProvider?.providerType || '';
const getProviderIcon = (row: llmType) =>
row.modelProvider?.icon || row.aiLlmProvider?.icon || '';
const getModelName = (row: llmType) =>
row.llmModel || (row as any).modelName || '-';
const getModelTypeLabel = (row: llmType) =>
modelTypeLabelMap[row.modelType] || row.modelType || '-';
const canVerify = (row: llmType) => row.modelType !== 'rerankModel';
const getModelId = (row: llmType) =>
String((row as any).id || (row as any).llmId || (row as any).modelId || '');
const getVerifyStatus = (row: llmType): VerifyButtonStatus =>
verifyStatusMap.value[getModelId(row)] || 'idle';
const setVerifyStatus = (id: string, status: VerifyButtonStatus) => {
if (!id) {
return;
}
verifyStatusMap.value[id] = status;
};
const isVerifying = (row: llmType) => getVerifyStatus(row) === 'loading';
const getVerifyButtonText = (row: llmType) => {
return getStatusButtonText(getVerifyStatus(row));
};
const getVerifyButtonIcon = (row: llmType) => {
const status = getVerifyStatus(row);
if (status === 'loading') {
return Loading;
}
if (status === 'success') {
return CircleCheck;
}
if (status === 'warning') {
return Warning;
}
if (status === 'error') {
return CircleClose;
}
return Select;
};
const getAbilityTags = (row: llmType): ModelAbilityItem[] =>
mapLlmToModelAbility(row, getDefaultModelAbility()).filter(
(tag) => tag.selected,
);
const totalCount = computed(() => modelRows.value.length);
const filteredRows = computed(() => {
const keyword = filterState.keyword.trim().toLowerCase();
return modelRows.value.filter((item) => {
if (
filterState.providerId &&
String((item as any).providerId) !== filterState.providerId
) {
return false;
}
if (filterState.modelType && item.modelType !== filterState.modelType) {
return false;
}
if (!keyword) {
return true;
}
const searchTargets = [
item.title,
getModelName(item),
item.groupName,
getProviderName(item),
]
.filter(Boolean)
.map((text) => String(text).toLowerCase());
return searchTargets.some((text) => text.includes(keyword));
});
});
const selectableRowCount = computed(() => selectedRows.value.length);
const loadModels = async () => {
isLoading.value = true;
lastErrorMessage.value = '';
try {
const res = await getModelList();
if (res.errorCode === 0) {
modelRows.value = res.data || [];
verifyStatusMap.value = {};
} else {
modelRows.value = [];
verifyStatusMap.value = {};
lastErrorMessage.value =
res.message || $t('ui.actionMessage.operationFailed');
}
} catch (error) {
modelRows.value = [];
verifyStatusMap.value = {};
lastErrorMessage.value =
(error as Error)?.message || $t('ui.actionMessage.operationFailed');
} finally {
isLoading.value = false;
}
};
const reloadAndNotify = async () => {
await loadModels();
emit('refreshProviderStats');
selectedRows.value = [];
};
const handleSelectionChange = (rows: llmType[]) => {
selectedRows.value = rows;
};
const handleEdit = (row: llmType) => {
const modelId = getModelId(row);
if (!modelId) {
ElMessage.warning('当前模型缺少ID无法编辑');
return;
}
emit('editModel', modelId);
};
const handleDelete = async (row: llmType) => {
const modelId = getModelId(row);
if (!modelId) {
ElMessage.warning('当前模型缺少ID无法删除');
return;
}
try {
await ElMessageBox.confirm(
`确认删除模型「${row.title}」吗?该操作不可恢复。`,
$t('message.noticeTitle'),
{
cancelButtonText: $t('message.cancel'),
confirmButtonText: $t('message.ok'),
type: 'warning',
},
);
} catch {
return;
}
isActionLoading.value = true;
try {
const res = await deleteLlm({ id: modelId });
if (res.errorCode === 0) {
ElMessage.success(res.message || '模型已删除');
await reloadAndNotify();
} else {
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
}
} finally {
isActionLoading.value = false;
}
};
const handleVerify = async (row: llmType) => {
const modelId = getModelId(row);
if (!canVerify(row) || !modelId || isVerifying(row)) {
if (!modelId) {
ElMessage.warning('当前模型缺少ID无法验证配置');
}
return;
}
setVerifyStatus(modelId, 'loading');
try {
const res = await verifyModelConfig(modelId);
const feedback = resolveModelVerificationFeedback(res, row.modelType);
setVerifyStatus(modelId, feedback.status);
if (feedback.status === 'success') {
ElMessage.success(feedback.message);
} else if (feedback.status === 'warning') {
ElMessage.warning(feedback.message);
} else {
if (!res.message) {
ElMessage.error(feedback.message);
}
}
} catch {
setVerifyStatus(modelId, 'error');
// error toast is already handled by global response interceptors
} finally {
// keep final status to show explicit success/failure state
}
};
const runBatchDelete = async (
ids: string[],
concurrency = 5,
): Promise<BatchDeleteResult> => {
const queue = [...ids];
const successIds: string[] = [];
const failed: Array<{ id: string; message: string }> = [];
const worker = async () => {
while (queue.length > 0) {
const id = queue.shift();
if (!id) {
return;
}
try {
const res = await deleteLlm({ id });
if (res.errorCode === 0) {
successIds.push(id);
} else {
failed.push({ id, message: res.message || '删除失败' });
}
} catch (error) {
failed.push({ id, message: (error as Error)?.message || '网络错误' });
}
}
};
const workerCount = Math.max(1, Math.min(concurrency, ids.length || 1));
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return {
failed,
successIds,
};
};
const handleBatchDelete = async () => {
const ids = selectedRows.value
.map((item) => getModelId(item))
.filter(Boolean);
if (ids.length === 0) {
ElMessage.warning('请先选择要删除的模型');
return;
}
try {
await ElMessageBox.confirm(
`确认批量删除 ${ids.length} 个模型吗?该操作不可恢复。`,
$t('message.noticeTitle'),
{
cancelButtonText: $t('message.cancel'),
confirmButtonText: $t('message.ok'),
type: 'warning',
},
);
} catch {
return;
}
isActionLoading.value = true;
try {
const result = await runBatchDelete(ids, 5);
const successCount = result.successIds.length;
const failCount = result.failed.length;
if (failCount === 0) {
ElMessage.success(`批量删除完成,共 ${successCount} 个模型`);
} else {
ElMessage.warning(
`批量删除完成,成功 ${successCount} 个,失败 ${failCount}`,
);
}
await reloadAndNotify();
} finally {
isActionLoading.value = false;
}
};
const resetFilters = () => {
filterState.keyword = '';
filterState.providerId = '';
filterState.modelType = '';
};
onMounted(loadModels);
defineExpose({
async reloadData() {
await loadModels();
},
});
</script>
<template>
<section class="active-workspace">
<header class="active-workspace__header">
<div class="active-workspace__summary">
<h3>已配置模型</h3>
<p> {{ totalCount }} 个模型</p>
</div>
</header>
<div class="active-workspace__filters">
<ElInput
v-model.trim="filterState.keyword"
clearable
placeholder="搜索模型名、模型ID、服务商、分组"
/>
<ElSelect
v-model="filterState.providerId"
clearable
placeholder="全部服务商"
>
<ElOption
v-for="provider in props.providers"
:key="provider.id"
:label="provider.providerName"
:value="provider.id"
/>
</ElSelect>
<ElSelect
v-model="filterState.modelType"
clearable
placeholder="全部模型类型"
>
<ElOption
v-for="type in modelTypeOptions"
:key="type.value"
:label="type.label"
:value="type.value"
/>
</ElSelect>
<ElButton @click="resetFilters">重置</ElButton>
</div>
<div v-if="selectableRowCount > 0" class="active-workspace__batch-bar">
<span>已选 {{ selectableRowCount }} </span>
<div class="active-workspace__batch-actions">
<ElButton
class="is-danger"
:disabled="isActionLoading"
@click="handleBatchDelete"
>
批量删除
</ElButton>
</div>
</div>
<div v-if="isLoading" class="active-workspace__state">
正在加载模型数据...
</div>
<div v-else-if="lastErrorMessage" class="active-workspace__state is-error">
{{ lastErrorMessage }}
</div>
<div v-else-if="modelRows.length === 0" class="active-workspace__empty">
<ElEmpty description="还没有模型,先添加一个模型开始使用。">
<ElButton type="primary" @click="emit('createModel', 'chatModel')">
添加模型
</ElButton>
</ElEmpty>
</div>
<div v-else-if="filteredRows.length === 0" class="active-workspace__empty">
<ElEmpty description="没有符合筛选条件的模型,试试调整筛选项。" />
</div>
<ElTable
v-else
row-key="id"
:data="filteredRows"
class="active-workspace__table"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="48" />
<ElTableColumn label="模型 ID" min-width="220">
<template #default="{ row }">
<div class="active-workspace__name-with-logo">
<ModelProviderBadge
:icon="getProviderIcon(row)"
:provider-name="getProviderName(row)"
:provider-type="getProviderType(row)"
:size="30"
/>
<div class="active-workspace__name-cell">
<strong>{{ row.title }}</strong>
<span>{{ getModelName(row) }}</span>
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="服务商" min-width="140">
<template #default="{ row }">
{{ getProviderName(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="类型" width="110">
<template #default="{ row }">
{{ getModelTypeLabel(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="分组" min-width="120">
<template #default="{ row }">
{{ row.groupName || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="能力" min-width="220">
<template #default="{ row }">
<div class="active-workspace__ability">
<ElTag
v-for="tag in getAbilityTags(row)"
:key="tag.value"
effect="plain"
size="small"
>
{{ tag.label }}
</ElTag>
<span v-if="getAbilityTags(row).length === 0">-</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="操作" min-width="290" fixed="right">
<template #default="{ row }">
<div class="active-workspace__actions">
<ElButton
v-if="canVerify(row)"
text
size="small"
class="active-workspace__verify-btn"
:class="`is-${getVerifyStatus(row)}`"
:disabled="
isVerifying(row) || isActionLoading || !getModelId(row)
"
@click="handleVerify(row)"
>
<template #icon>
<ElIcon
class="active-workspace__verify-icon"
:class="`is-${getVerifyStatus(row)}`"
>
<component :is="getVerifyButtonIcon(row)" />
</ElIcon>
</template>
{{ getVerifyButtonText(row) }}
</ElButton>
<ElButton text size="small" :icon="Edit" @click="handleEdit(row)">
编辑
</ElButton>
<ElButton
text
size="small"
class="is-danger"
:icon="Delete"
@click="handleDelete(row)"
>
删除
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
</section>
</template>
<style scoped>
.active-workspace {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
}
.active-workspace__header {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid hsl(var(--divider-faint) / 58%);
}
.active-workspace__summary h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.active-workspace__summary p,
.active-workspace__state {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--text-muted));
}
.active-workspace__filters {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(0, 1fr)) auto;
gap: 12px;
}
.active-workspace__batch-bar {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: hsl(var(--surface-contrast-soft) / 68%);
border: 1px solid hsl(var(--divider-faint) / 58%);
border-radius: 12px;
}
.active-workspace__batch-bar span {
font-size: 13px;
color: hsl(var(--text-muted));
}
.active-workspace__batch-actions {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.active-workspace__batch-actions .is-danger,
.active-workspace__actions .is-danger {
color: hsl(var(--destructive));
}
.active-workspace__state {
padding: 14px 0;
}
.active-workspace__state.is-error {
color: hsl(var(--destructive));
}
.active-workspace__empty {
display: flex;
flex: 1;
}
.active-workspace__empty :deep(.el-empty) {
margin: auto;
}
.active-workspace__table {
flex: 1;
}
.active-workspace__name-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.active-workspace__name-with-logo {
display: flex;
gap: 10px;
align-items: center;
}
.active-workspace__name-cell strong {
font-size: 14px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.active-workspace__name-cell span {
font-size: 12px;
color: hsl(var(--text-muted));
}
.active-workspace__ability {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.active-workspace__actions {
display: inline-flex;
gap: 4px;
align-items: center;
}
.active-workspace__verify-btn.is-idle {
color: hsl(var(--text-muted));
}
.active-workspace__verify-btn.is-loading {
color: hsl(var(--primary));
}
.active-workspace__verify-btn.is-success {
color: hsl(var(--success));
}
.active-workspace__verify-btn.is-warning {
color: hsl(var(--warning));
}
.active-workspace__verify-btn.is-error {
color: hsl(var(--destructive));
}
.active-workspace__verify-icon {
transition:
color 0.24s ease,
transform 0.24s ease;
}
.active-workspace__verify-icon.is-loading {
animation: active-workspace-verify-spin 0.9s linear infinite;
}
.active-workspace__verify-icon.is-success,
.active-workspace__verify-icon.is-warning,
.active-workspace__verify-icon.is-error {
animation: active-workspace-verify-pop 0.32s ease;
}
@keyframes active-workspace-verify-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes active-workspace-verify-pop {
0% {
opacity: 0.72;
transform: scale(0.82);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 1024px) {
.active-workspace__filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.active-workspace__header {
flex-direction: column;
}
.active-workspace__filters {
grid-template-columns: minmax(0, 1fr);
}
.active-workspace__batch-bar {
flex-direction: column;
align-items: flex-start;
}
}
</style>