You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
719 lines
38 KiB
719 lines
38 KiB
<template>
|
|
<t-card title="" :bordered="false" header-bordered>
|
|
<template #title><h2 style="margin:0;font-size:16px;">⚙️ AI 大模型配置管理</h2></template>
|
|
|
|
<!-- F4: 健康状态汇总卡片 -->
|
|
<div v-if="healthSummary" class="health-summary" style="display:flex;gap:12px;margin:12px 0;flex-wrap:wrap;">
|
|
<div style="padding:10px 16px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;min-width:100px;text-align:center;">
|
|
<div style="font-size:22px;font-weight:700;color:#16a34a;">{{ healthSummary.online }}</div>
|
|
<div style="font-size:11px;color:#166534;">🟢 在线</div>
|
|
</div>
|
|
<div style="padding:10px 16px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;min-width:100px;text-align:center;">
|
|
<div style="font-size:22px;font-weight:700;color:#dc2626;">{{ healthSummary.offline }}</div>
|
|
<div style="font-size:11px;color:#991b1b;">🔴 离线</div>
|
|
</div>
|
|
<div v-if="healthSummary.unknown" style="padding:10px 16px;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;min-width:100px;text-align:center;">
|
|
<div style="font-size:22px;font-weight:700;color:#d97706;">{{ healthSummary.unknown }}</div>
|
|
<div style="font-size:11px;color:#92400e;">🟡 未检测</div>
|
|
</div>
|
|
<div style="padding:10px 16px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;min-width:100px;text-align:center;">
|
|
<div style="font-size:22px;font-weight:700;color:#374151;">{{ healthSummary.total }}</div>
|
|
<div style="font-size:11px;color:#6b7280;">📊 总计</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 筛选栏 + 操作按钮 -->
|
|
<div class="toolbar">
|
|
<t-select v-model="filterAppType" :options="appTypeOptions" style="max-width:200px;" size="small" @change="load(1)" />
|
|
<t-button theme="primary" size="small" @click="openAddModal">➕ 新建配置</t-button>
|
|
<t-button variant="outline" size="small" @click="exportConfigs">📤 导出</t-button>
|
|
<t-button variant="outline" size="small" @click="showImportDialog = true">📥 导入</t-button>
|
|
<t-button variant="outline" size="small" @click="load()">🔄 刷新</t-button>
|
|
</div>
|
|
|
|
<!-- F6: Fallback 链可视化 -->
|
|
<div v-if="fallbackChains.length > 0" class="fallback-section">
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
|
<span style="font-size:13px;font-weight:600;color:#1e40af;">🔗 Fallback 链(按优先级排列)</span>
|
|
<t-tag size="small" variant="light" theme="primary" style="cursor:pointer;" @click="showFallbackHelp = !showFallbackHelp">❓ 帮助</t-tag>
|
|
</div>
|
|
<div v-if="showFallbackHelp" class="fallback-help">
|
|
<div style="font-weight:600;color:#1e40af;margin-bottom:8px;">📖 Fallback 链使用说明</div>
|
|
<p>Fallback 链是<span style="color:#059669;font-weight:600;">高可用性机制</span>,主模型故障时自动切换到备用模型。</p>
|
|
<p>✔ 勾选「设为活跃配置」启用 | 📊 调整「优先级」数值(越大越优先)| 🔀 拖拽列表项快速调整</p>
|
|
</div>
|
|
<div v-for="chain in fallbackChains" :key="chain.appType" style="margin-bottom:8px;">
|
|
<div style="font-size:12px;color:#1e40af;font-weight:600;">{{ getAppTypeLabel(chain.appType) }}</div>
|
|
<div v-for="(item, idx) in chain.configs" :key="item.id"
|
|
style="display:flex;align-items:center;gap:6px;padding:4px 8px;margin:2px 0;background:white;border-radius:4px;font-size:12px;cursor:grab;border:1px solid #e5e7eb;"
|
|
draggable="true"
|
|
@dragstart="onDragStart($event, item, chain)"
|
|
@dragover.prevent
|
|
@drop="onDrop($event, item, chain)">
|
|
<span style="color:#6b7280;font-weight:600;">#{{ idx + 1 }}</span>
|
|
<span v-if="idx < chain.configs.length - 1" style="color:#93c5fd;">↓</span>
|
|
<span v-else style="color:#d1d5db;">·</span>
|
|
<strong>{{ item.name }}</strong>
|
|
<code style="font-size:11px;background:#f3f4f6;padding:1px 4px;border-radius:3px;">{{ item.model_name }}</code>
|
|
<span style="color:#6b7280;">P={{ item.priority }}</span>
|
|
<span v-if="healthMap[item.id]" :title="getHealthTooltip(item.id)"
|
|
:style="{ color: healthMap[item.id].status === 'ONLINE' ? '#16a34a' : '#dc2626' }">
|
|
{{ healthMap[item.id].status === 'ONLINE' ? '🟢' : healthMap[item.id].status === 'UNKNOWN' ? '🟡' : '🔴' }}
|
|
<span v-if="healthMap[item.id].latencyMs" style="font-size:10px;">{{ healthMap[item.id].latencyMs }}ms</span>
|
|
</span>
|
|
<span v-else style="color:#d1d5db;" title="未检测">🟡</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 配置列表表格 -->
|
|
<div style="overflow-x:auto;">
|
|
<t-table :data="configs" :columns="tableColumns" row-key="id" :loading="tableLoading" hover stripe
|
|
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }"
|
|
@page-change="onPageChange">
|
|
<template #app_type="{ row }">
|
|
<t-tag size="small" variant="light" theme="primary">{{ getAppTypeLabel(row.app_type) }}</t-tag>
|
|
</template>
|
|
<template #model_name="{ row }">
|
|
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ row.model_name }}</code>
|
|
</template>
|
|
<template #base_url="{ row }">
|
|
<span style="font-size:12px;color:#6b7280;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" :title="row.base_url || getProviderBaseUrl(row.provider)">
|
|
{{ row.base_url || getProviderBaseUrl(row.provider) || '-' }}
|
|
</span>
|
|
</template>
|
|
<template #api_key="{ row }">
|
|
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ row.api_key || '-' }}</code>
|
|
</template>
|
|
<template #status="{ row }">
|
|
<t-tag size="small" :theme="row.is_active ? 'success' : 'default'" variant="light">
|
|
{{ row.is_active ? '🟢 活跃' : '⚫ 未激活' }}
|
|
</t-tag>
|
|
<span v-if="row.is_active && healthMap[row.id]" :title="getHealthTooltip(row.id)"
|
|
:style="{ cursor: 'help', color: healthMap[row.id].status === 'ONLINE' ? '#16a34a' : healthMap[row.id].status === 'UNKNOWN' ? '#d97706' : '#dc2626', marginLeft: '4px' }">
|
|
{{ healthMap[row.id].status === 'ONLINE' ? '●' : '●' }}
|
|
</span>
|
|
</template>
|
|
<template #op="{ row }">
|
|
<t-space :size="4">
|
|
<t-button size="small" variant="text" @click="openEditModal(row)" title="编辑">✏️</t-button>
|
|
<t-button size="small" variant="text" @click="testConnection(row)" :disabled="testLoading[row.id]" title="测试连接">{{ testLoading[row.id] ? '⏳' : '🔌' }}</t-button>
|
|
<t-button size="small" variant="text" @click="duplicateConfig(row)" title="复制">📋</t-button>
|
|
<t-button v-if="!row.is_active" size="small" variant="text" theme="primary" @click="activate(row.id)" title="激活">▶️</t-button>
|
|
<t-button v-if="row.is_active" size="small" variant="text" @click="deactivateConfig(row.id)" title="停用">⏸️</t-button>
|
|
<t-button v-if="!row.is_active" size="small" variant="text" theme="danger" @click="remove(row.id, row.name)" title="删除">🗑️</t-button>
|
|
</t-space>
|
|
</template>
|
|
</t-table>
|
|
</div>
|
|
|
|
<!-- F1: 测试结果提示 -->
|
|
<div v-if="testResult.visible" class="test-result-toast"
|
|
:style="{ position: 'fixed', bottom: '20px', right: '20px', padding: '12px 20px', borderRadius: '8px', zIndex: 9999, color: 'white', maxWidth: '400px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
|
background: testResult.success ? '#16a34a' : '#dc2626' }">
|
|
<div style="font-weight:600;">{{ testResult.success ? '✅ 连接成功' : '❌ 连接失败' }}</div>
|
|
<div style="font-size:12px;margin-top:4px;">延迟:{{ testResult.latencyMs }}ms<span v-if="testResult.dimensions"> · 维度:{{ testResult.dimensions }}</span></div>
|
|
<div v-if="testResult.errorMessage" style="font-size:11px;margin-top:4px;opacity:0.9;">{{ testResult.errorMessage }}</div>
|
|
</div>
|
|
|
|
<!-- ==================== 编辑/新建弹窗 ==================== -->
|
|
<t-dialog
|
|
v-model:visible="editModal.visible"
|
|
:header="editModal.mode === 'add' ? '➕ 新建模型配置' : '✏️ 编辑模型配置'"
|
|
width="720px"
|
|
:footer="false"
|
|
:close-on-overlay-click="false"
|
|
>
|
|
<div style="font-size:12px;color:#6b7280;margin-bottom:16px;">💡 核心三要素:API Key + API 地址 + 模型名称,填入即可使用</div>
|
|
<t-form label-align="top">
|
|
<!-- 应用类型 -->
|
|
<t-form-item label="应用类型">
|
|
<t-radio-group v-model="editModal.form.app_type" :disabled="editModal.mode === 'edit'">
|
|
<t-radio-button v-for="tab in appTypeTabs" :key="tab.value" :value="tab.value">{{ tab.shortLabel }}</t-radio-button>
|
|
</t-radio-group>
|
|
</t-form-item>
|
|
|
|
<!-- API Key -->
|
|
<t-form-item label="🔑 API Key(必填)">
|
|
<t-space style="width:100%;">
|
|
<t-input v-model="editModal.form.api_key" :type="showApiKey ? 'text' : 'password'"
|
|
:placeholder="editModal.mode === 'edit' ? '留空则保留原值不变' : '请输入 API Key'" style="flex:1;"
|
|
@blur="onApiKeyFilled" />
|
|
<t-button variant="outline" size="small" @click="showApiKey = !showApiKey">{{ showApiKey ? '🙈 隐藏' : '👁️ 显示' }}</t-button>
|
|
</t-space>
|
|
<template #help>
|
|
<span v-if="editModal.mode === 'edit'" style="font-size:11px;color:#6b7280;">💡 API Key 已脱敏,留空保留原值</span>
|
|
</template>
|
|
</t-form-item>
|
|
|
|
<!-- API 地址 -->
|
|
<t-form-item label="🌐 API 地址">
|
|
<t-input v-model="editModal.form.base_url" :placeholder="providerBaseUrlPlaceholder || '输入 API 地址'" @blur="onBaseUrlBlur" />
|
|
<template #help>
|
|
<t-space size="small" style="font-size:12px;margin-top:4px;align-items:center;">
|
|
<span style="color:#6b7280;">快捷选择:</span>
|
|
<t-select v-model="editModal.form.provider" :options="providerOptions" style="max-width:200px;" size="small" @change="onProviderChange" />
|
|
<span v-if="providerTip" style="color:#3b82f6;">💡 {{ providerTip }}</span>
|
|
</t-space>
|
|
</template>
|
|
</t-form-item>
|
|
|
|
<!-- 模型名称 -->
|
|
<t-form-item label="🤖 模型名称(必填)">
|
|
<t-space style="width:100%;">
|
|
<t-input v-model="editModal.form.model_name" placeholder="输入模型名称,或点击「获取模型」"
|
|
style="flex:1;" @focus="onModelInputFocus" @input="onModelInputChange"
|
|
@keydown.down.prevent="onDropdownKeyDown('down')" @keydown.up.prevent="onDropdownKeyDown('up')"
|
|
@keydown.enter.prevent="onDropdownKeyDown('enter')" @keydown.escape="modelDropdownOpen = false">
|
|
<template #suffix>
|
|
<t-button v-if="editModal.form.model_name" variant="text" size="small"
|
|
@click="editModal.form.model_name = ''; modelDropdownOpen = false; updateNameSuggestion()" style="color:#9ca3af;">✕</t-button>
|
|
</template>
|
|
</t-input>
|
|
<t-button variant="outline" size="small" @click="fetchModelsFromApi" :disabled="fetchLoading"
|
|
style="border-color:var(--td-brand-color);color:var(--td-brand-color);white-space:nowrap;">
|
|
{{ fetchLoading ? '⏳ 获取中...' : '🔍 获取模型' }}
|
|
</t-button>
|
|
</t-space>
|
|
<!-- 自定义下拉列表 -->
|
|
<div v-if="modelDropdownOpen && filteredModels.length > 0" class="model-dropdown"
|
|
style="position:relative;top:100%;left:0;right:0;z-index:100;background:white;border:1px solid #d1d5db;border-radius:8px;max-height:240px;overflow-y:auto;margin-top:4px;box-shadow:0 4px 12px rgba(0,0,0,0.12);">
|
|
<div v-for="(m, idx) in filteredModels" :key="m.id"
|
|
@click="selectModel(m)" @mouseenter="modelDropdownHighlight = idx"
|
|
:style="{ padding: '8px 12px', cursor: 'pointer', fontSize: '13px',
|
|
borderBottom: idx < filteredModels.length - 1 ? '1px solid #f3f4f6' : 'none',
|
|
background: idx === modelDropdownHighlight ? '#eff6ff' : 'white' }">
|
|
<div style="font-weight:500;">{{ m.id }}</div>
|
|
<div v-if="m.owned_by" style="font-size:11px;color:#6b7280;margin-top:1px;">{{ m.owned_by }}</div>
|
|
</div>
|
|
</div>
|
|
<template #help>
|
|
<div style="font-size:11px;color:#6b7280;margin-top:4px;">💡 填入 API Key 和地址后点击「获取模型」自动填充</div>
|
|
<div v-if="fetchedModels.length > 0" style="font-size:11px;color:#16a34a;margin-top:2px;">✅ 已获取 {{ fetchedModels.length }} 个模型</div>
|
|
</template>
|
|
</t-form-item>
|
|
|
|
<!-- 高级参数(折叠) -->
|
|
<t-form-item>
|
|
<t-collapse v-model="advancedCollapse">
|
|
<t-collapse-panel header="⚡ 高级参数" value="advanced">
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
|
<t-form-item label="温度 (Temperature)">
|
|
<t-input-number v-model="editModal.form.temperature" :min="0" :max="2" :step="0.1" placeholder="0.7" style="width:100%;" />
|
|
</t-form-item>
|
|
<t-form-item label="最大 Token 数">
|
|
<t-input-number v-model="editModal.form.max_tokens" :min="1" :max="128000" placeholder="2000" style="width:100%;" />
|
|
</t-form-item>
|
|
<t-form-item label="Top P">
|
|
<t-input-number v-model="editModal.form.topP" :min="0" :max="1" :step="0.01" placeholder="不设置" style="width:100%;" />
|
|
</t-form-item>
|
|
<t-form-item label="Top K">
|
|
<t-input-number v-model="editModal.form.topK" :min="1" :max="100" placeholder="不限制" style="width:100%;" />
|
|
</t-form-item>
|
|
<t-form-item label="频率惩罚">
|
|
<t-input-number v-model="editModal.form.frequencyPenalty" :min="-2" :max="2" :step="0.1" placeholder="0" style="width:100%;" />
|
|
</t-form-item>
|
|
<t-form-item label="存在惩罚">
|
|
<t-input-number v-model="editModal.form.presencePenalty" :min="-2" :max="2" :step="0.1" placeholder="0" style="width:100%;" />
|
|
</t-form-item>
|
|
<t-form-item label="停止词(逗号分隔)" style="grid-column:1/-1;">
|
|
<t-input v-model="editModal.form.stopSequences" placeholder="如:END,STOP" />
|
|
</t-form-item>
|
|
</div>
|
|
</t-collapse-panel>
|
|
</t-collapse>
|
|
</t-form-item>
|
|
|
|
<!-- 向量维度(仅 EMBEDDING) -->
|
|
<t-form-item v-if="editModal.form.app_type === 'EMBEDDING'" label="📐 向量维度(必填)">
|
|
<t-input-number v-model="editModal.form.embeddingDimensions" :min="1" :max="8192" placeholder="1024" style="max-width:240px;" />
|
|
<template #help>
|
|
<span style="font-size:11px;color:#d97706;">⚠️ 修改维度后需重建向量表</span>
|
|
</template>
|
|
</t-form-item>
|
|
|
|
<!-- 配置名称 -->
|
|
<t-form-item label="📝 配置名称(必填)">
|
|
<t-input v-model="editModal.form.name" :placeholder="nameSuggestion || '如:生产环境-DeepSeek对话'" />
|
|
<template #help>
|
|
<span v-if="nameSuggestion && !editModal.form.name" style="font-size:11px;color:#3b82f6;">
|
|
💡 建议:<a @click.prevent="editModal.form.name = nameSuggestion" style="color:var(--td-brand-color);cursor:pointer;text-decoration:underline;">{{ nameSuggestion }}</a>
|
|
</span>
|
|
</template>
|
|
</t-form-item>
|
|
|
|
<!-- 优先级 + 激活 -->
|
|
<t-row :gutter="16">
|
|
<t-col :span="6">
|
|
<t-form-item label="优先级">
|
|
<t-input-number v-model="editModal.form.priority" :min="0" placeholder="0(越大越优先)" style="width:100%;" />
|
|
</t-form-item>
|
|
</t-col>
|
|
<t-col :span="6">
|
|
<t-form-item label=" ">
|
|
<t-checkbox v-model="editModal.form.is_active">设为活跃配置(同类型支持多个按优先级 Fallback)</t-checkbox>
|
|
</t-form-item>
|
|
</t-col>
|
|
</t-row>
|
|
|
|
<!-- 描述 -->
|
|
<t-form-item label="描述说明">
|
|
<t-textarea v-model="editModal.form.description" :autosize="{ minRows: 2, maxRows: 4 }" placeholder="可选,填写配置用途说明" />
|
|
</t-form-item>
|
|
</t-form>
|
|
|
|
<!-- 按钮 -->
|
|
<div class="dialog-footer" style="gap:10px;margin-top:20px;">
|
|
<t-button variant="outline" @click="editModal.visible = false">取消</t-button>
|
|
<t-button v-if="editModal.mode === 'edit'" variant="outline" @click="testConnectionById(editModal.editId!)"
|
|
:disabled="testLoading[editModal.editId!]" style="border-color:var(--td-brand-color);color:var(--td-brand-color);">
|
|
{{ testLoading[editModal.editId!] ? '⏳ 测试中...' : '🔌 测试连接' }}
|
|
</t-button>
|
|
<t-button theme="primary" @click="saveConfig">💾 保存</t-button>
|
|
</div>
|
|
</t-dialog>
|
|
|
|
<!-- F7: 导入弹窗 -->
|
|
<t-dialog v-model:visible="showImportDialog" header="📥 导入模型配置" width="600px" :footer="false">
|
|
<t-upload :auto-upload="false" theme="file" accept=".json" @change="onImportFileSelect" />
|
|
<div v-if="importPreview" style="margin:12px 0;padding:12px;background:#f9fafb;border-radius:8px;">
|
|
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">导入预览</div>
|
|
<div style="font-size:12px;">共 {{ importPreview.configs.length }} 条配置</div>
|
|
<div v-for="c in importPreview.configs.slice(0, 5)" :key="c.name" style="font-size:11px;color:#6b7280;margin:2px 0;">
|
|
· {{ c.name }} ({{ c.appType || c.app_type }}) - {{ c.modelName || c.model_name }}
|
|
</div>
|
|
</div>
|
|
<t-form-item label="冲突策略">
|
|
<t-select v-model="importConflict" :options="importConflictOptions" style="max-width:300px;" />
|
|
</t-form-item>
|
|
<div v-if="importResult" style="margin:12px 0;padding:12px;border-radius:8px;"
|
|
:style="{ background: importResult.errors?.length ? '#fef2f2' : '#f0fdf4' }">
|
|
<div style="font-size:13px;font-weight:600;">导入完成</div>
|
|
<div style="font-size:12px;margin-top:4px;">新增 {{ importResult.added }} · 覆盖 {{ importResult.overwritten }} · 跳过 {{ importResult.skipped }}</div>
|
|
</div>
|
|
<div class="dialog-footer" style="gap:10px;margin-top:20px;">
|
|
<t-button variant="outline" @click="closeImportDialog">关闭</t-button>
|
|
<t-button theme="primary" @click="doImport" :disabled="!importPreview">📥 确认导入</t-button>
|
|
</div>
|
|
</t-dialog>
|
|
</t-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
|
|
import { MessagePlugin } from 'tdesign-vue-next'
|
|
import * as api from '@/api/model-config'
|
|
import { toast } from '@/utils/toast'
|
|
import { useConfirm } from '@/composables/useConfirm'
|
|
|
|
const { confirm } = useConfirm()
|
|
|
|
// ==================== 常量 ====================
|
|
const appTypeOptions = [
|
|
{ label: '全部类型', value: '' },
|
|
{ label: '💬 智能客服对话', value: 'CHAT' },
|
|
{ label: '📐 文本向量化', value: 'EMBEDDING' },
|
|
{ label: '🔄 RAG查询重写', value: 'RAG_REWRITE' },
|
|
{ label: '📊 重排序', value: 'RERANK' },
|
|
]
|
|
|
|
const appTypeTabs = [
|
|
{ value: 'CHAT', label: '💬 对话', shortLabel: '对话' },
|
|
{ value: 'EMBEDDING', label: '📐 向量化', shortLabel: '向量化' },
|
|
{ value: 'RAG_REWRITE', label: '🔄 RAG重写', shortLabel: 'RAG重写' },
|
|
{ value: 'RERANK', label: '📊 重排序', shortLabel: '重排序' },
|
|
]
|
|
|
|
const providerOptions = [
|
|
{ label: '手动填写地址', value: '' },
|
|
{ label: '通义千问 (DashScope)', value: 'dashscope' },
|
|
{ label: 'DeepSeek (深度求索)', value: 'deepseek' },
|
|
{ label: '豆包 (字节跳动)', value: 'volcengine' },
|
|
{ label: 'Kimi (月之暗面)', value: 'moonshot' },
|
|
{ label: '智谱 AI (GLM)', value: 'zhipu' },
|
|
{ label: 'OpenAI', value: 'openai' },
|
|
]
|
|
|
|
const PROVIDER_BASE_URLS: Record<string, string> = {
|
|
dashscope: 'https://dashscope.aliyuncs.com/compatible-mode',
|
|
deepseek: 'https://api.deepseek.com',
|
|
volcengine: 'https://ark.cn-beijing.volces.com/api/v3',
|
|
moonshot: 'https://api.moonshot.cn/v1',
|
|
zhipu: 'https://open.bigmodel.cn/api/paas/v4',
|
|
openai: 'https://api.openai.com',
|
|
}
|
|
|
|
const PROVIDER_TIPS: Record<string, string> = {
|
|
dashscope: '通义千问团队版/私有化部署需填写专属 Base URL,公共版可留空',
|
|
volcengine: '模型名称可在火山引擎 ARK 控制台获取',
|
|
}
|
|
|
|
const importConflictOptions = [
|
|
{ label: '跳过同名配置', value: 'skip' },
|
|
{ label: '覆盖同名配置', value: 'overwrite' },
|
|
]
|
|
|
|
// ==================== 表格列定义 ====================
|
|
const tableColumns = [
|
|
{ colKey: 'name', title: '配置名称', width: 160 },
|
|
{ colKey: 'app_type', title: '应用类型', width: 100 },
|
|
{ colKey: 'model_name', title: '模型名称', width: 160 },
|
|
{ colKey: 'base_url', title: 'API 地址', width: 180 },
|
|
{ colKey: 'api_key', title: 'API Key', width: 180 },
|
|
{ colKey: 'status', title: '状态', width: 120 },
|
|
{ colKey: 'op', title: '操作', width: 240 },
|
|
]
|
|
|
|
// ==================== 状态 ====================
|
|
const configs = ref<any[]>([])
|
|
const currentPage = ref(1)
|
|
const pageSize = ref(10)
|
|
const total = ref(0)
|
|
const tableLoading = ref(false)
|
|
const filterAppType = ref('')
|
|
const showApiKey = ref(false)
|
|
|
|
// 模型下拉框
|
|
const fetchedModels = ref<any[]>([])
|
|
const fetchLoading = ref(false)
|
|
const modelDropdownOpen = ref(false)
|
|
const modelDropdownHighlight = ref(-1)
|
|
const providerTip = ref('')
|
|
const providerBaseUrlPlaceholder = ref('')
|
|
const nameSuggestion = ref('')
|
|
const advancedCollapse = ref<string[]>([])
|
|
|
|
const filteredModels = computed(() => {
|
|
const query = (editModal.value.form.model_name || '').trim().toLowerCase()
|
|
if (!query) return fetchedModels.value
|
|
return fetchedModels.value.filter((m: any) =>
|
|
m.id.toLowerCase().includes(query) || (m.owned_by && m.owned_by.toLowerCase().includes(query)))
|
|
})
|
|
|
|
// 健康状态
|
|
const healthMap = ref<Record<string, any>>({})
|
|
const healthSummary = ref<any>(null)
|
|
let healthTimer: any = null
|
|
|
|
// 测试
|
|
const testLoading = ref<Record<string, boolean>>({})
|
|
const testResult = reactive({ visible: false, success: false, latencyMs: 0, dimensions: null as any, errorMessage: null as any })
|
|
let testResultTimer: any = null
|
|
|
|
// Fallback 链
|
|
const fallbackChains = ref<any[]>([])
|
|
const showFallbackHelp = ref(false)
|
|
|
|
// 导入
|
|
const showImportDialog = ref(false)
|
|
const importPreview = ref<any>(null)
|
|
const importConflict = ref('skip')
|
|
const importResult = ref<any>(null)
|
|
|
|
// 编辑弹窗
|
|
const editModal = ref<any>({
|
|
visible: false,
|
|
mode: 'add',
|
|
editId: null,
|
|
form: createEmptyForm(),
|
|
})
|
|
|
|
function createEmptyForm() {
|
|
return {
|
|
name: '', app_type: 'CHAT', provider: '', api_key: '', model_name: '',
|
|
temperature: 0.7, max_tokens: 2000, base_url: '', embeddingDimensions: 1024,
|
|
topP: null, topK: null, frequencyPenalty: null, presencePenalty: null, stopSequences: '',
|
|
priority: 0, is_active: false, description: '',
|
|
}
|
|
}
|
|
|
|
// ==================== 提供商切换 ====================
|
|
function onProviderChange() {
|
|
const p = editModal.value.form.provider
|
|
if (!p) { editModal.value.form.base_url = ''; providerTip.value = ''; providerBaseUrlPlaceholder.value = '输入自定义 API 地址'; fetchedModels.value = []; updateNameSuggestion(); return }
|
|
editModal.value.form.base_url = PROVIDER_BASE_URLS[p] || ''
|
|
providerBaseUrlPlaceholder.value = PROVIDER_BASE_URLS[p] || ''
|
|
providerTip.value = PROVIDER_TIPS[p] || ''
|
|
fetchedModels.value = []
|
|
updateNameSuggestion()
|
|
}
|
|
|
|
function onBaseUrlBlur() {
|
|
const url = editModal.value.form.base_url
|
|
if (!url || editModal.value.form.provider) return
|
|
for (const [p, du] of Object.entries(PROVIDER_BASE_URLS)) {
|
|
if (url.startsWith(du) || du.startsWith(url)) {
|
|
editModal.value.form.provider = p
|
|
providerTip.value = PROVIDER_TIPS[p] || ''
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
function onApiKeyFilled() { /* UI 提示 */ }
|
|
|
|
// ==================== 模型下拉框 ====================
|
|
function onModelInputFocus() { if (fetchedModels.value.length > 0) { modelDropdownOpen.value = true; modelDropdownHighlight.value = -1 } }
|
|
function onModelInputChange() { if (fetchedModels.value.length > 0) { modelDropdownOpen.value = true; modelDropdownHighlight.value = -1 } }
|
|
|
|
function onDropdownKeyDown(dir: string) {
|
|
if (!modelDropdownOpen.value || filteredModels.value.length === 0) {
|
|
if (fetchedModels.value.length > 0) { modelDropdownOpen.value = true; modelDropdownHighlight.value = 0 }
|
|
return
|
|
}
|
|
if (dir === 'down') modelDropdownHighlight.value = Math.min(modelDropdownHighlight.value + 1, filteredModels.value.length - 1)
|
|
else if (dir === 'up') modelDropdownHighlight.value = Math.max(modelDropdownHighlight.value - 1, 0)
|
|
else if (dir === 'enter') {
|
|
if (modelDropdownHighlight.value >= 0 && modelDropdownHighlight.value < filteredModels.value.length)
|
|
selectModel(filteredModels.value[modelDropdownHighlight.value])
|
|
else modelDropdownOpen.value = false
|
|
}
|
|
}
|
|
|
|
function selectModel(m: any) { editModal.value.form.model_name = m.id; modelDropdownOpen.value = false; modelDropdownHighlight.value = -1; updateNameSuggestion() }
|
|
|
|
function onDocumentClick(e: Event) {
|
|
const el = e.target as HTMLElement
|
|
if (el.closest?.('.model-dropdown') || el.closest?.('.t-input')) return
|
|
modelDropdownOpen.value = false
|
|
}
|
|
|
|
// ==================== 获取模型列表 ====================
|
|
async function fetchModelsFromApi() {
|
|
const form = editModal.value.form
|
|
if (!form.api_key?.trim()) { toast('请先填写 API Key', 'error'); return }
|
|
if (!form.base_url?.trim()) {
|
|
if (form.provider && PROVIDER_BASE_URLS[form.provider]) form.base_url = PROVIDER_BASE_URLS[form.provider]
|
|
else { toast('请填写 API 地址', 'error'); return }
|
|
}
|
|
fetchLoading.value = true
|
|
try {
|
|
const json = await api.fetchModels(form.base_url, form.api_key, form.provider)
|
|
if (json.success && json.data) {
|
|
fetchedModels.value = json.data
|
|
toast('已获取 ' + json.data.length + ' 个模型', 'success')
|
|
modelDropdownOpen.value = true; modelDropdownHighlight.value = 0
|
|
} else { toast(json.message || '获取失败', 'error'); fetchedModels.value = [] }
|
|
} catch (e: any) { toast('获取失败:' + e.message, 'error'); fetchedModels.value = [] }
|
|
finally { fetchLoading.value = false }
|
|
}
|
|
|
|
function updateNameSuggestion() {
|
|
const f = editModal.value.form
|
|
if (f.name) { nameSuggestion.value = ''; return }
|
|
const pl = getProviderLabel(f.provider)
|
|
if (f.model_name?.trim()) nameSuggestion.value = (pl || '自定义') + '-' + f.model_name.trim()
|
|
else nameSuggestion.value = ''
|
|
}
|
|
|
|
watch(() => editModal.value.form.model_name, updateNameSuggestion)
|
|
watch(() => editModal.value.form.provider, updateNameSuggestion)
|
|
|
|
// ==================== 数据加载 ====================
|
|
async function load(p = currentPage.value) {
|
|
tableLoading.value = true
|
|
currentPage.value = p
|
|
try {
|
|
const json = await api.listModelConfigs(p, pageSize.value, filterAppType.value || undefined)
|
|
if (json.success) {
|
|
configs.value = json.data || []
|
|
total.value = json.total || 0
|
|
loadHealthStatus(); buildFallbackChains()
|
|
} else toast(json.message || '查询失败', 'error')
|
|
} catch (e: any) { toast('加载失败:' + e.message, 'error') }
|
|
finally { tableLoading.value = false }
|
|
}
|
|
|
|
function onPageChange(pageInfo: { current: number; pageSize: number }) { pageSize.value = pageInfo.pageSize; load(pageInfo.current) }
|
|
|
|
// ==================== 健康状态 ====================
|
|
async function loadHealthStatus() {
|
|
try {
|
|
const json = await api.getHealthStatus()
|
|
if (json.success && json.data) {
|
|
const data = { ...json.data }
|
|
healthSummary.value = data._summary || null
|
|
delete data._summary
|
|
healthMap.value = data
|
|
}
|
|
} catch { /* 静默失败 */ }
|
|
}
|
|
|
|
function getHealthTooltip(cid: string) {
|
|
const h = healthMap.value[cid]
|
|
if (!h) return '未检测'
|
|
let t = h.status === 'ONLINE' ? '在线' : h.status === 'UNKNOWN' ? '未支持自动检测' : '离线'
|
|
if (h.latencyMs) t += ' · ' + h.latencyMs + 'ms'
|
|
return t
|
|
}
|
|
|
|
// ==================== Fallback 链 ====================
|
|
function buildFallbackChains() {
|
|
const chains = []
|
|
for (const at of ['CHAT', 'EMBEDDING', 'RAG_REWRITE', 'RERANK']) {
|
|
const ac = configs.value.filter((c: any) => c.app_type === at && c.is_active).sort((a: any, b: any) => (b.priority || 0) - (a.priority || 0))
|
|
if (ac.length > 0) chains.push({ appType: at, configs: ac })
|
|
}
|
|
fallbackChains.value = chains
|
|
}
|
|
|
|
let dragItem: any = null; let dragChain: any = null
|
|
function onDragStart(e: DragEvent, item: any, chain: any) { dragItem = item; dragChain = chain; e.dataTransfer!.effectAllowed = 'move' }
|
|
async function onDrop(e: DragEvent, target: any) {
|
|
if (!dragItem || dragItem.id === target.id) return
|
|
try {
|
|
await api.updateModelConfigPriority(dragItem.id, target.priority || 0)
|
|
await api.updateModelConfigPriority(target.id, dragItem.priority || 0)
|
|
toast('优先级已调整', 'success')
|
|
load()
|
|
} catch (e: any) { toast('调整失败:' + e.message, 'error') }
|
|
dragItem = null; dragChain = null
|
|
}
|
|
|
|
// ==================== 弹窗 ====================
|
|
function openAddModal() {
|
|
editModal.value = { visible: true, mode: 'add', editId: null, form: createEmptyForm() }
|
|
showApiKey.value = false; advancedCollapse.value = []
|
|
fetchedModels.value = []; providerTip.value = ''; providerBaseUrlPlaceholder.value = ''; nameSuggestion.value = ''
|
|
modelDropdownOpen.value = false; modelDropdownHighlight.value = -1
|
|
}
|
|
|
|
function openEditModal(config: any) {
|
|
let extra: any = {}
|
|
const raw = config.extra_config || config.extraConfig
|
|
if (raw) { try { extra = typeof raw === 'string' ? JSON.parse(raw) : raw } catch { } }
|
|
editModal.value = {
|
|
visible: true, mode: 'edit', editId: config.id,
|
|
form: {
|
|
name: config.name || '', app_type: config.app_type || 'CHAT', provider: config.provider || '',
|
|
api_key: '', model_name: config.model_name || '', temperature: config.temperature, max_tokens: config.max_tokens,
|
|
base_url: config.base_url || '', embeddingDimensions: extra.dimensions || 1024,
|
|
topP: extra.topP ?? null, topK: extra.topK ?? null,
|
|
frequencyPenalty: extra.frequencyPenalty ?? null, presencePenalty: extra.presencePenalty ?? null,
|
|
stopSequences: extra.stopSequences || '', priority: config.priority || 0,
|
|
is_active: config.is_active || false, description: config.description || '',
|
|
},
|
|
}
|
|
showApiKey.value = false; advancedCollapse.value = []
|
|
fetchedModels.value = []; nameSuggestion.value = ''
|
|
modelDropdownOpen.value = false; modelDropdownHighlight.value = -1
|
|
if (config.provider && PROVIDER_BASE_URLS[config.provider]) { providerBaseUrlPlaceholder.value = PROVIDER_BASE_URLS[config.provider]; providerTip.value = PROVIDER_TIPS[config.provider] || '' }
|
|
else { providerBaseUrlPlaceholder.value = ''; providerTip.value = '' }
|
|
}
|
|
|
|
// ==================== 保存 ====================
|
|
function toCamelCase(form: any) {
|
|
const data: any = { name: form.name, appType: form.app_type, provider: form.provider || 'other', apiKey: form.api_key, modelName: form.model_name, temperature: form.temperature, maxTokens: form.max_tokens, baseUrl: form.base_url, priority: form.priority, isActive: form.is_active, description: form.description }
|
|
const extraConfig: any = {}
|
|
if (form.app_type === 'EMBEDDING') extraConfig.dimensions = form.embeddingDimensions || 1024
|
|
if (form.topP != null) extraConfig.topP = form.topP
|
|
if (form.topK != null) extraConfig.topK = form.topK
|
|
if (form.frequencyPenalty != null) extraConfig.frequencyPenalty = form.frequencyPenalty
|
|
if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty
|
|
if (form.stopSequences?.trim()) extraConfig.stopSequences = form.stopSequences.trim()
|
|
data.extraConfig = extraConfig
|
|
return data
|
|
}
|
|
|
|
async function saveConfig() {
|
|
const form = editModal.value.form
|
|
if (!form.name?.trim()) { toast('请填写配置名称', 'error'); return }
|
|
if (!form.api_key?.trim() && editModal.value.mode === 'add') { toast('请填写 API Key', 'error'); return }
|
|
if (!form.model_name?.trim()) { toast('请填写模型名称', 'error'); return }
|
|
if (!form.provider) form.provider = 'other'
|
|
try {
|
|
const data = toCamelCase(form)
|
|
let json
|
|
if (editModal.value.mode === 'add') json = await api.createModelConfig(data)
|
|
else { if (!data.apiKey?.trim()) delete data.apiKey; json = await api.updateModelConfig(editModal.value.editId, data) }
|
|
if (json.success) { toast(editModal.value.mode === 'add' ? '配置创建成功' : '配置更新成功', 'success'); editModal.value.visible = false; load() }
|
|
else toast(json.message || '操作失败', 'error')
|
|
} catch (e: any) { toast('保存失败:' + e.message, 'error') }
|
|
}
|
|
|
|
// ==================== 测试连接 ====================
|
|
async function testConnection(config: any) {
|
|
testLoading.value = { ...testLoading.value, [config.id]: true }
|
|
try { showTestResult(await api.testModelConfig(config.id)) } catch (e: any) { showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) }
|
|
finally { testLoading.value = { ...testLoading.value, [config.id]: false } }
|
|
}
|
|
|
|
async function testConnectionById(id: string) {
|
|
testLoading.value = { ...testLoading.value, [id]: true }
|
|
try { showTestResult(await api.testModelConfig(id)) } catch (e: any) { showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) }
|
|
finally { testLoading.value = { ...testLoading.value, [id]: false } }
|
|
}
|
|
|
|
function showTestResult(r: any) {
|
|
if (testResultTimer) clearTimeout(testResultTimer)
|
|
testResult.visible = true; testResult.success = r.success; testResult.latencyMs = r.latencyMs || 0; testResult.dimensions = r.dimensions || null; testResult.errorMessage = r.errorMessage || r.message || null
|
|
testResultTimer = setTimeout(() => { testResult.visible = false }, 5000)
|
|
}
|
|
|
|
// ==================== 复制/激活/停用/删除 ====================
|
|
async function duplicateConfig(c: any) {
|
|
if (!await confirm(`确定复制配置「${c.name || c.id}」?`)) return
|
|
try { const j = await api.duplicateModelConfig(c.id); if (j.success) { toast('配置复制成功', 'success'); load() } else toast(j.message || '复制失败', 'error') }
|
|
catch (e: any) { toast('复制失败:' + e.message, 'error') }
|
|
}
|
|
|
|
async function activate(id: string) {
|
|
if (!await confirm('确定激活此配置?')) return
|
|
try { const j = await api.activateModelConfig(id); if (j.success) { toast('配置已激活', 'success'); load() } else toast(j.message || '激活失败', 'error') }
|
|
catch (e: any) { toast('激活失败:' + e.message, 'error') }
|
|
}
|
|
|
|
async function deactivateConfig(id: string) {
|
|
if (!await confirm('确定停用此配置?')) return
|
|
try { const j = await api.deactivateModelConfig(id); if (j.success) { toast('配置已停用', 'success'); load() } else toast(j.message || '停用失败', 'error') }
|
|
catch (e: any) { toast('停用失败:' + e.message, 'error') }
|
|
}
|
|
|
|
async function remove(id: string, name: string) {
|
|
if (!await confirm(`确定删除「${name || id}」?`)) return
|
|
try { const j = await api.deleteModelConfig(id); if (j.success) { toast('已删除', 'success'); load() } else toast(j.message || '删除失败', 'error') }
|
|
catch (e: any) { toast('删除失败:' + e.message, 'error') }
|
|
}
|
|
|
|
// ==================== 导入/导出 ====================
|
|
async function exportConfigs() {
|
|
try {
|
|
const j = await api.exportModelConfigs()
|
|
if (j.success) { const blob = new Blob([JSON.stringify(j.data, null, 2)], { type: 'application/json' }); const u = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = u; a.download = 'model-configs-' + new Date().toISOString().slice(0, 10) + '.json'; a.click(); URL.revokeObjectURL(u); toast('导出成功', 'success') }
|
|
else toast(j.message || '导出失败', 'error')
|
|
} catch (e: any) { toast('导出失败:' + e.message, 'error') }
|
|
}
|
|
|
|
function onImportFileSelect(file: any) {
|
|
if (!file?.raw) return
|
|
const reader = new FileReader()
|
|
reader.onload = (ev: any) => {
|
|
try { let d = JSON.parse(ev.target.result); if (!Array.isArray(d) && d.data) d = d.data; if (!Array.isArray(d)) { toast('JSON 格式不正确', 'error'); return }; importPreview.value = { configs: d }; importResult.value = null }
|
|
catch (e: any) { toast('文件解析失败:' + e.message, 'error') }
|
|
}
|
|
reader.readAsText(file.raw)
|
|
}
|
|
|
|
async function doImport() {
|
|
if (!importPreview.value) return
|
|
try { const j = await api.importModelConfigs(importPreview.value.configs, importConflict.value); if (j.success) { importResult.value = j.data; toast('导入完成', 'success'); load() } else toast(j.message || '导入失败', 'error') }
|
|
catch (e: any) { toast('导入失败:' + e.message, 'error') }
|
|
}
|
|
|
|
function closeImportDialog() { showImportDialog.value = false; importPreview.value = null; importResult.value = null }
|
|
|
|
// ==================== 工具函数 ====================
|
|
function getAppTypeLabel(t: string) { const m: Record<string, string> = { CHAT: '💬 对话', EMBEDDING: '📐 向量化', RAG_REWRITE: '🔄 RAG重写', RERANK: '📊 重排序' }; return m[t] || t }
|
|
function getProviderLabel(p: string) { const f = providerOptions.find(o => o.value === p); return f ? f.label : p }
|
|
function getProviderBaseUrl(p: string) { return PROVIDER_BASE_URLS[p] || '' }
|
|
|
|
// ==================== 生命周期 ====================
|
|
onMounted(() => { load(); healthTimer = setInterval(loadHealthStatus, 30000); document.addEventListener('click', onDocumentClick, true) })
|
|
onUnmounted(() => { clearInterval(healthTimer); clearTimeout(testResultTimer); document.removeEventListener('click', onDocumentClick, true) })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fallback-section { margin:12px 0; padding:12px; background:#eff6ff; border:1px solid #bfdbfe; border-radius:8px; }
|
|
.fallback-help { margin-bottom:12px; padding:12px; background:white; border:1px solid #93c5fd; border-radius:6px; font-size:12px; line-height:1.6; color:#374151; }
|
|
.fallback-help p { margin:4px 0; }
|
|
</style>
|