ai-robot-core/ai-service-admin/src/views/admin/llm/index.vue

471 lines
10 KiB
Vue
Raw Normal View History

<template>
<div class="llm-config-page">
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">LLM 模型配置</h1>
<p class="page-desc">配置和管理系统使用的大语言模型支持多种提供者切换配置修改后需保存才能生效</p>
</div>
<div class="header-actions" v-if="currentConfig.updated_at">
<div class="update-info">
<el-icon class="update-icon"><Clock /></el-icon>
<span>上次更新: {{ formatDate(currentConfig.updated_at) }}</span>
</div>
</div>
</div>
</div>
<el-row :gutter="24" v-loading="pageLoading" element-loading-text="加载中...">
<el-col :xs="24" :sm="24" :md="12" :lg="12">
<el-card shadow="hover" class="config-card">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper llm-icon">
<el-icon><Cpu /></el-icon>
</div>
<span class="header-title">模型配置</span>
</div>
</div>
</template>
<div class="card-content">
<div class="provider-select-section">
<div class="section-label">
<el-icon><Connection /></el-icon>
<span>选择提供者</span>
</div>
<ProviderSelect
v-model="currentConfig.provider"
:providers="providers"
:loading="providersLoading"
placeholder="请选择 LLM 提供者"
@change="handleProviderChange"
/>
<transition name="fade">
<div v-if="currentProvider" class="provider-info">
<el-icon class="info-icon"><InfoFilled /></el-icon>
<span class="info-text">{{ currentProvider.description }}</span>
</div>
</transition>
</div>
<el-divider />
<transition name="slide-fade" mode="out-in">
<div v-if="currentConfig.provider" key="form" class="config-form-section">
<ConfigForm
ref="configFormRef"
:schema="configSchema"
v-model="currentConfig.config"
label-width="140px"
/>
</div>
<el-empty v-else key="empty" description="请先选择一个 LLM 提供者" :image-size="120">
<template #image>
<div class="empty-icon">
<el-icon><Box /></el-icon>
</div>
</template>
</el-empty>
</transition>
</div>
<template #footer>
<div class="card-footer">
<el-button size="large" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
<el-button type="primary" size="large" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存配置
</el-button>
</div>
</template>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="12" :lg="12">
<TestPanel
:test-fn="handleTest"
:can-test="!!currentConfig.provider"
title="LLM 连接测试"
input-label="测试提示词"
input-placeholder="请输入测试提示词(可选,默认使用系统预设提示词)"
:show-token-stats="true"
/>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Cpu, Connection, InfoFilled, Box, RefreshLeft, Check, Clock } from '@element-plus/icons-vue'
import { useLLMStore } from '@/stores/llm'
import ProviderSelect from '@/components/common/ProviderSelect.vue'
import ConfigForm from '@/components/common/ConfigForm.vue'
import TestPanel from '@/components/common/TestPanel.vue'
import type { TestResult } from '@/components/common/TestPanel.vue'
const llmStore = useLLMStore()
const configFormRef = ref<InstanceType<typeof ConfigForm>>()
const saving = ref(false)
const pageLoading = ref(false)
const providers = computed(() => llmStore.providers)
const currentConfig = computed(() => llmStore.currentConfig)
const currentProvider = computed(() => llmStore.currentProvider)
const configSchema = computed(() => llmStore.configSchema)
const providersLoading = computed(() => llmStore.providersLoading)
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const handleProviderChange = (provider: any) => {
if (provider?.name) {
llmStore.setProvider(provider.name)
}
}
const handleSave = async () => {
if (!currentConfig.value.provider) {
ElMessage.warning('请先选择 LLM 提供者')
return
}
try {
const valid = await configFormRef.value?.validate()
if (!valid) {
return
}
} catch (error) {
ElMessage.warning('请检查配置表单中的必填项')
return
}
saving.value = true
try {
await llmStore.saveCurrentConfig()
ElMessage.success('配置保存成功')
} catch (error) {
ElMessage.error('配置保存失败')
} finally {
saving.value = false
}
}
const handleReset = async () => {
try {
await ElMessageBox.confirm('确定要重置配置吗?将恢复为当前保存的配置。', '确认重置', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await llmStore.loadConfig()
ElMessage.success('配置已重置')
} catch (error) {
}
}
const handleTest = async (input?: string): Promise<TestResult> => {
return await llmStore.runTest(input)
}
const initPage = async () => {
pageLoading.value = true
try {
await Promise.all([
llmStore.loadProviders(),
llmStore.loadConfig()
])
} catch (error) {
ElMessage.error('初始化页面失败')
} finally {
pageLoading.value = false
}
}
onMounted(() => {
initPage()
})
</script>
<style scoped>
.llm-config-page {
padding: 24px;
min-height: calc(100vh - 60px);
}
.page-header {
margin-bottom: 24px;
animation: slideDown 0.4s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.title-section {
flex: 1;
min-width: 300px;
}
.page-title {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.page-desc {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
.header-actions {
display: flex;
align-items: center;
}
.update-info {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background-color: var(--bg-tertiary);
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.update-icon {
font-size: 14px;
color: var(--text-tertiary);
}
.config-card {
animation: fadeInUp 0.5s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.icon-wrapper {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--primary-lighter);
border-radius: 10px;
color: var(--primary-color);
font-size: 18px;
}
.icon-wrapper.llm-icon {
background: linear-gradient(135deg, #E0F2FE 0%, #BAE6FD 100%);
color: #0284C7;
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.card-content {
padding: 8px 0;
}
.provider-select-section {
margin-bottom: 16px;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.section-label .el-icon {
color: var(--primary-color);
}
.provider-info {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 14px;
padding: 14px 16px;
background-color: var(--bg-tertiary);
border-radius: 10px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
border-left: 3px solid var(--primary-color);
}
.info-icon {
margin-top: 2px;
color: var(--primary-color);
font-size: 16px;
}
.info-text {
flex: 1;
}
.config-form-section {
max-height: 400px;
overflow-y: auto;
padding-right: 8px;
}
.config-form-section::-webkit-scrollbar {
width: 6px;
}
.config-form-section::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
.config-form-section::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
}
.config-form-section::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.card-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 8px;
}
.empty-icon {
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-tertiary);
border-radius: 50%;
margin: 0 auto;
}
.empty-icon .el-icon {
font-size: 48px;
color: var(--text-tertiary);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-fade-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-fade-leave-active {
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(-16px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(16px);
}
@media (max-width: 768px) {
.llm-config-page {
padding: 16px;
}
.page-title {
font-size: 20px;
}
.header-content {
flex-direction: column;
}
.title-section {
min-width: 100%;
}
.config-form-section {
max-height: 300px;
}
}
</style>