375 lines
8.4 KiB
Vue
375 lines
8.4 KiB
Vue
<script setup lang="ts">
|
||
import type { PropType } from 'vue';
|
||
|
||
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 { ref } from 'vue';
|
||
|
||
import {
|
||
CircleCheck,
|
||
CircleClose,
|
||
Delete,
|
||
Edit,
|
||
Loading,
|
||
Select,
|
||
Warning,
|
||
} from '@element-plus/icons-vue';
|
||
import { ElButton, ElIcon, ElMessage, ElTag } from 'element-plus';
|
||
|
||
import { verifyModelConfig } from '#/api/ai/llm';
|
||
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';
|
||
|
||
const props = defineProps({
|
||
llmList: {
|
||
type: Array as PropType<llmType[]>,
|
||
default: () => [],
|
||
},
|
||
needHiddenSettingIcon: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits(['deleteLlm', 'editLlm']);
|
||
const verifyStatusMap = ref<Record<string, VerifyButtonStatus>>({});
|
||
const getModelId = (llm: llmType) =>
|
||
String((llm as any).id || (llm as any).llmId || (llm as any).modelId || '');
|
||
const getVerifyStatus = (llm: llmType): VerifyButtonStatus =>
|
||
verifyStatusMap.value[getModelId(llm)] || 'idle';
|
||
const isVerifying = (llm: llmType) => getVerifyStatus(llm) === 'loading';
|
||
const setVerifyStatus = (id: string, status: VerifyButtonStatus) => {
|
||
if (!id) {
|
||
return;
|
||
}
|
||
verifyStatusMap.value[id] = status;
|
||
};
|
||
const getVerifyButtonText = (llm: llmType) => {
|
||
return getStatusButtonText(getVerifyStatus(llm));
|
||
};
|
||
const getVerifyButtonIcon = (llm: llmType) => {
|
||
const status = getVerifyStatus(llm);
|
||
if (status === 'loading') {
|
||
return Loading;
|
||
}
|
||
if (status === 'success') {
|
||
return CircleCheck;
|
||
}
|
||
if (status === 'warning') {
|
||
return Warning;
|
||
}
|
||
if (status === 'error') {
|
||
return CircleClose;
|
||
}
|
||
return Select;
|
||
};
|
||
|
||
const handleDeleteLlm = (id: string) => {
|
||
emit('deleteLlm', id);
|
||
};
|
||
|
||
const handleEditLlm = (id: string) => {
|
||
emit('editLlm', id);
|
||
};
|
||
|
||
const canVerify = (llm: llmType) => llm.modelType !== 'rerankModel';
|
||
|
||
const handleVerifyLlm = async (llm: llmType) => {
|
||
const modelId = getModelId(llm);
|
||
if (!modelId || isVerifying(llm)) {
|
||
if (!modelId) {
|
||
ElMessage.warning('当前模型缺少ID,无法验证配置');
|
||
}
|
||
return;
|
||
}
|
||
|
||
setVerifyStatus(modelId, 'loading');
|
||
|
||
try {
|
||
const res = await verifyModelConfig(modelId);
|
||
|
||
const feedback = resolveModelVerificationFeedback(res, llm.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 for explicit visual feedback
|
||
}
|
||
};
|
||
|
||
const getSelectedAbilityTagsForLlm = (llm: llmType): ModelAbilityItem[] => {
|
||
const defaultAbility = getDefaultModelAbility();
|
||
const allTags = mapLlmToModelAbility(llm, defaultAbility);
|
||
return allTags.filter((tag) => tag.selected);
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<div class="llm-list">
|
||
<article v-for="llm in props.llmList" :key="llm.id" class="llm-item">
|
||
<div class="llm-item__main">
|
||
<ModelProviderBadge
|
||
:icon="llm.modelProvider?.icon"
|
||
:provider-name="llm.modelProvider?.providerName"
|
||
:provider-type="llm.modelProvider?.providerType"
|
||
:size="40"
|
||
/>
|
||
<div class="llm-item__content">
|
||
<div class="llm-item__headline">
|
||
<h4 class="llm-item__title">{{ llm.title }}</h4>
|
||
<div
|
||
v-if="getSelectedAbilityTagsForLlm(llm).length > 0"
|
||
class="llm-item__tags"
|
||
>
|
||
<ElTag
|
||
v-for="tag in getSelectedAbilityTagsForLlm(llm)"
|
||
:key="tag.value"
|
||
effect="plain"
|
||
size="small"
|
||
class="llm-item__tag"
|
||
>
|
||
{{ tag.label }}
|
||
</ElTag>
|
||
</div>
|
||
</div>
|
||
<p v-if="llm.description" class="llm-item__description">
|
||
{{ llm.description }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="llm-item__actions">
|
||
<ElButton
|
||
v-if="canVerify(llm)"
|
||
text
|
||
size="small"
|
||
class="llm-item__verify-btn"
|
||
:class="`is-${getVerifyStatus(llm)}`"
|
||
:disabled="isVerifying(llm) || !getModelId(llm)"
|
||
@click="handleVerifyLlm(llm)"
|
||
>
|
||
<template #icon>
|
||
<ElIcon
|
||
class="llm-item__verify-icon"
|
||
:class="`is-${getVerifyStatus(llm)}`"
|
||
>
|
||
<component :is="getVerifyButtonIcon(llm)" />
|
||
</ElIcon>
|
||
</template>
|
||
{{ getVerifyButtonText(llm) }}
|
||
</ElButton>
|
||
|
||
<ElButton
|
||
v-if="!needHiddenSettingIcon"
|
||
text
|
||
size="small"
|
||
:icon="Edit"
|
||
@click="handleEditLlm(llm.id)"
|
||
>
|
||
编辑
|
||
</ElButton>
|
||
|
||
<ElButton
|
||
text
|
||
size="small"
|
||
:icon="Delete"
|
||
class="llm-item__danger"
|
||
@click="handleDeleteLlm(llm.id)"
|
||
>
|
||
删除
|
||
</ElButton>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.llm-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.llm-item {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
padding: 16px 18px;
|
||
background: hsl(var(--surface-panel) / 90%);
|
||
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||
border-radius: 18px;
|
||
transition:
|
||
border-color 0.2s ease,
|
||
transform 0.2s ease,
|
||
box-shadow 0.2s ease;
|
||
}
|
||
|
||
.llm-item:hover {
|
||
border-color: hsl(var(--primary) / 28%);
|
||
box-shadow: 0 18px 30px -28px hsl(var(--foreground) / 34%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.llm-item__main {
|
||
display: flex;
|
||
flex: 1;
|
||
gap: 14px;
|
||
align-items: flex-start;
|
||
min-width: 0;
|
||
}
|
||
|
||
.llm-item__content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.llm-item__headline {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.llm-item__title {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
color: hsl(var(--text-strong));
|
||
}
|
||
|
||
.llm-item__description {
|
||
display: -webkit-box;
|
||
margin: 0;
|
||
overflow: hidden;
|
||
-webkit-line-clamp: 2;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
color: hsl(var(--text-muted));
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
|
||
.llm-item__tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.llm-item__tag {
|
||
color: hsl(var(--text-strong));
|
||
background: hsl(var(--primary) / 8%);
|
||
border-color: transparent;
|
||
}
|
||
|
||
.llm-item__actions {
|
||
display: inline-flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.llm-item__actions :deep(.el-button) {
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.llm-item__verify-btn.is-idle {
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.llm-item__verify-btn.is-loading {
|
||
color: hsl(var(--primary));
|
||
}
|
||
|
||
.llm-item__verify-btn.is-success {
|
||
color: hsl(var(--success));
|
||
}
|
||
|
||
.llm-item__verify-btn.is-warning {
|
||
color: hsl(var(--warning));
|
||
}
|
||
|
||
.llm-item__verify-btn.is-error {
|
||
color: hsl(var(--destructive));
|
||
}
|
||
|
||
.llm-item__verify-icon {
|
||
transition:
|
||
color 0.24s ease,
|
||
transform 0.24s ease;
|
||
}
|
||
|
||
.llm-item__verify-icon.is-loading {
|
||
animation: llm-item-verify-spin 0.9s linear infinite;
|
||
}
|
||
|
||
.llm-item__verify-icon.is-success,
|
||
.llm-item__verify-icon.is-warning,
|
||
.llm-item__verify-icon.is-error {
|
||
animation: llm-item-verify-pop 0.32s ease;
|
||
}
|
||
|
||
.llm-item__danger {
|
||
color: hsl(var(--danger) / 90%);
|
||
}
|
||
|
||
.llm-item__danger:hover,
|
||
.llm-item__danger:focus-visible {
|
||
color: hsl(var(--danger));
|
||
}
|
||
|
||
@keyframes llm-item-verify-spin {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes llm-item-verify-pop {
|
||
0% {
|
||
opacity: 0.72;
|
||
transform: scale(0.82);
|
||
}
|
||
|
||
100% {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.llm-item {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.llm-item__actions {
|
||
justify-content: flex-start;
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|