|
|
|
@ -1,8 +1,10 @@ |
|
|
|
/** |
|
|
|
* ⚙️ AI 模型配置管理组件 |
|
|
|
* 展示模型配置列表、新增/编辑/激活/删除配置 |
|
|
|
* 展示模型配置列表、新增/编辑/激活/删除/测试/复制/导入导出配置 |
|
|
|
* F1: 连接测试 | F2: 配置复制 | F3: 高级参数面板 | F4: 健康看板 |
|
|
|
* F5: 模型能力元信息 | F6: Fallback 链 | F7: 导入导出 |
|
|
|
*/ |
|
|
|
import { ref, onMounted, watch } from 'vue' |
|
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' |
|
|
|
import * as api from '../js/api.js' |
|
|
|
import { toast, formatDate } from '../js/utils.js' |
|
|
|
|
|
|
|
@ -11,7 +13,8 @@ const APP_TYPE_OPTIONS = [ |
|
|
|
{ value: '', label: '全部类型' }, |
|
|
|
{ value: 'CHAT', label: '智能客服对话' }, |
|
|
|
{ value: 'EMBEDDING', label: '文本向量化' }, |
|
|
|
{ value: 'RAG_REWRITE', label: 'RAG查询重写' } |
|
|
|
{ value: 'RAG_REWRITE', label: 'RAG查询重写' }, |
|
|
|
{ value: 'RERANK', label: '重排序' } |
|
|
|
] |
|
|
|
|
|
|
|
// 提供商选项
|
|
|
|
@ -25,20 +28,29 @@ const PROVIDER_OPTIONS = [ |
|
|
|
{ value: 'other', label: '其他' } |
|
|
|
] |
|
|
|
|
|
|
|
// 提供商默认配置(切换时自动填充)
|
|
|
|
// chatModels / embeddingModels 按 app_type 分别展示,models 作为向后兼容兜底
|
|
|
|
// F5: 提供商默认配置(含模型能力元信息)
|
|
|
|
const PROVIDER_DEFAULTS = { |
|
|
|
dashscope: { |
|
|
|
baseUrl: '', |
|
|
|
chatModels: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-long'], |
|
|
|
embeddingModels: ['text-embedding-v2'], |
|
|
|
chatModels: [ |
|
|
|
{ name: 'qwen-turbo', maxContextTokens: 131072, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'qwen-plus', maxContextTokens: 131072, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'qwen-max', maxContextTokens: 32768, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'qwen-long', maxContextTokens: 10000000, maxOutputTokens: 6000, supportsVision: false, supportsFunctionCall: false } |
|
|
|
], |
|
|
|
embeddingModels: [ |
|
|
|
{ name: 'text-embedding-v2', maxContextTokens: 8192, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false } |
|
|
|
], |
|
|
|
defaultChatModel: 'qwen-turbo', |
|
|
|
defaultEmbeddingModel: 'text-embedding-v2', |
|
|
|
tip: '通义千问团队版/私有化部署需填写专属 Base URL,公共版可留空' |
|
|
|
}, |
|
|
|
deepseek: { |
|
|
|
baseUrl: 'https://api.deepseek.com', |
|
|
|
chatModels: ['deepseek-chat', 'deepseek-reasoner'], |
|
|
|
chatModels: [ |
|
|
|
{ name: 'deepseek-chat', maxContextTokens: 65536, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'deepseek-reasoner', maxContextTokens: 65536, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: false } |
|
|
|
], |
|
|
|
embeddingModels: [], |
|
|
|
defaultChatModel: 'deepseek-chat', |
|
|
|
defaultEmbeddingModel: '', |
|
|
|
@ -46,15 +58,29 @@ const PROVIDER_DEFAULTS = { |
|
|
|
}, |
|
|
|
volcengine: { |
|
|
|
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', |
|
|
|
chatModels: ['doubao-1-5-pro-32k', 'doubao-1-5-lite-32k', 'doubao-pro-32k', 'doubao-1-5-thinking-pro', 'doubao-seed-1.6'], |
|
|
|
embeddingModels: ['doubao-embedding-text-240515', 'doubao-embedding-large', 'doubao-embedding-vision-251215'], |
|
|
|
chatModels: [ |
|
|
|
{ name: 'doubao-1-5-pro-32k', maxContextTokens: 32768, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'doubao-1-5-lite-32k', maxContextTokens: 32768, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'doubao-pro-32k', maxContextTokens: 32768, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'doubao-1-5-thinking-pro', maxContextTokens: 131072, maxOutputTokens: 16384, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'doubao-seed-1.6', maxContextTokens: 131072, maxOutputTokens: 16384, supportsVision: true, supportsFunctionCall: true } |
|
|
|
], |
|
|
|
embeddingModels: [ |
|
|
|
{ name: 'doubao-embedding-text-240515', maxContextTokens: 8192, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false }, |
|
|
|
{ name: 'doubao-embedding-large', maxContextTokens: 8192, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false }, |
|
|
|
{ name: 'doubao-embedding-vision-251215', maxContextTokens: 8192, maxOutputTokens: 0, supportsVision: true, supportsFunctionCall: false } |
|
|
|
], |
|
|
|
defaultChatModel: 'doubao-1-5-pro-32k', |
|
|
|
defaultEmbeddingModel: 'doubao-embedding-text-240515', |
|
|
|
tip: '豆包对话推荐 doubao-1-5-pro-32k;向量化推荐 doubao-embedding-text-240515(2048维)。模型名称可在火山引擎 ARK 控制台获取' |
|
|
|
}, |
|
|
|
moonshot: { |
|
|
|
baseUrl: 'https://api.moonshot.cn/v1', |
|
|
|
chatModels: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'], |
|
|
|
chatModels: [ |
|
|
|
{ name: 'moonshot-v1-8k', maxContextTokens: 8192, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'moonshot-v1-32k', maxContextTokens: 32768, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'moonshot-v1-128k', maxContextTokens: 131072, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true } |
|
|
|
], |
|
|
|
embeddingModels: [], |
|
|
|
defaultChatModel: 'moonshot-v1-8k', |
|
|
|
defaultEmbeddingModel: '', |
|
|
|
@ -62,7 +88,12 @@ const PROVIDER_DEFAULTS = { |
|
|
|
}, |
|
|
|
zhipu: { |
|
|
|
baseUrl: 'https://open.bigmodel.cn/api/paas/v4', |
|
|
|
chatModels: ['glm-4-plus', 'glm-4-flash', 'glm-4-long', 'glm-4'], |
|
|
|
chatModels: [ |
|
|
|
{ name: 'glm-4-plus', maxContextTokens: 128000, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'glm-4-flash', maxContextTokens: 128000, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'glm-4-long', maxContextTokens: 1000000, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, |
|
|
|
{ name: 'glm-4', maxContextTokens: 128000, maxOutputTokens: 4096, supportsVision: true, supportsFunctionCall: true } |
|
|
|
], |
|
|
|
embeddingModels: [], |
|
|
|
defaultChatModel: 'glm-4-flash', |
|
|
|
defaultEmbeddingModel: '', |
|
|
|
@ -70,8 +101,16 @@ const PROVIDER_DEFAULTS = { |
|
|
|
}, |
|
|
|
openai: { |
|
|
|
baseUrl: 'https://api.openai.com', |
|
|
|
chatModels: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'], |
|
|
|
embeddingModels: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'], |
|
|
|
chatModels: [ |
|
|
|
{ name: 'gpt-4o', maxContextTokens: 128000, maxOutputTokens: 16384, supportsVision: true, supportsFunctionCall: true }, |
|
|
|
{ name: 'gpt-4o-mini', maxContextTokens: 128000, maxOutputTokens: 16384, supportsVision: true, supportsFunctionCall: true }, |
|
|
|
{ name: 'gpt-3.5-turbo', maxContextTokens: 16385, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true } |
|
|
|
], |
|
|
|
embeddingModels: [ |
|
|
|
{ name: 'text-embedding-3-small', maxContextTokens: 8191, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false }, |
|
|
|
{ name: 'text-embedding-3-large', maxContextTokens: 8191, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false }, |
|
|
|
{ name: 'text-embedding-ada-002', maxContextTokens: 8191, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false } |
|
|
|
], |
|
|
|
defaultChatModel: 'gpt-4o-mini', |
|
|
|
defaultEmbeddingModel: 'text-embedding-3-small', |
|
|
|
tip: '' |
|
|
|
@ -86,20 +125,75 @@ const PROVIDER_DEFAULTS = { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 格式化工具:Token 数显示
|
|
|
|
function formatTokens(n) { |
|
|
|
if (!n) return '未知' |
|
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M' |
|
|
|
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'K' |
|
|
|
return String(n) |
|
|
|
} |
|
|
|
|
|
|
|
export default { |
|
|
|
template: `
|
|
|
|
<div class="card"> |
|
|
|
<h2>⚙️ AI 大模型配置管理</h2> |
|
|
|
|
|
|
|
<!-- 筛选栏 --> |
|
|
|
<div class="input-row"> |
|
|
|
<!-- 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 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="input-row" style="flex-wrap:wrap;"> |
|
|
|
<select class="input" v-model="filterAppType" @change="load(1)" style="max-width:200px;"> |
|
|
|
<option v-for="opt in appTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option> |
|
|
|
</select> |
|
|
|
<button class="btn btn-primary btn-sm" @click="openAddModal">➕ 新建配置</button> |
|
|
|
<!-- F7: 导入/导出 --> |
|
|
|
<button class="btn btn-outline btn-sm" @click="exportConfigs">📤 导出</button> |
|
|
|
<button class="btn btn-outline btn-sm" @click="showImportDialog = true">📥 导入</button> |
|
|
|
<button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- F6: Fallback 链可视化(仅当有活跃配置时显示) --> |
|
|
|
<div v-if="fallbackChains.length > 0" style="margin:12px 0;padding:12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;"> |
|
|
|
<div style="font-size:13px;font-weight:600;color:#1e40af;margin-bottom:8px;">🔗 Fallback 链(按优先级排列,主模型在顶部)</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;" |
|
|
|
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> |
|
|
|
<!-- F4: 健康状态指示 --> |
|
|
|
<span v-if="healthMap[item.id]" :title="getHealthTooltip(item.id)" |
|
|
|
:style="{ color: healthMap[item.id].status === 'ONLINE' ? '#16a34a' : '#dc2626' }"> |
|
|
|
{{ healthMap[item.id].status === 'ONLINE' ? '🟢' : '🔴' }} |
|
|
|
<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;"> |
|
|
|
<table class="data-table"> |
|
|
|
@ -111,7 +205,7 @@ export default { |
|
|
|
<th>提供商</th> |
|
|
|
<th>温度</th> |
|
|
|
<th>API Key</th> |
|
|
|
<th>状态</th> |
|
|
|
<th>F4: 状态</th> |
|
|
|
<th>操作</th> |
|
|
|
</tr> |
|
|
|
</thead> |
|
|
|
@ -127,22 +221,48 @@ export default { |
|
|
|
<td> |
|
|
|
<span class="badge" :class="getAppTypeBadgeClass(c.app_type)">{{ getAppTypeLabel(c.app_type) }}</span> |
|
|
|
</td> |
|
|
|
<td><code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.model_name }}</code></td> |
|
|
|
<td> |
|
|
|
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.model_name }}</code> |
|
|
|
<!-- F5: 模型能力标签 --> |
|
|
|
<div v-if="getModelMeta(c)" style="font-size:10px;color:#6b7280;margin-top:2px;"> |
|
|
|
{{ formatTokens(getModelMeta(c).maxContextTokens) }} ctx · {{ formatTokens(getModelMeta(c).maxOutputTokens) }} out |
|
|
|
<span v-if="getModelMeta(c).supportsVision"> · 👁</span> |
|
|
|
<span v-if="getModelMeta(c).supportsFunctionCall"> · 🔧</span> |
|
|
|
</div> |
|
|
|
</td> |
|
|
|
<td>{{ getProviderLabel(c.provider) }}</td> |
|
|
|
<td>{{ c.temperature != null ? c.temperature : '-' }}</td> |
|
|
|
<td><code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.api_key || '-' }}</code></td> |
|
|
|
<!-- F4: 健康状态列 --> |
|
|
|
<td> |
|
|
|
<span v-if="c.is_active" style="color:#16a34a;font-weight:600;"> |
|
|
|
🟢 活跃 |
|
|
|
</span> |
|
|
|
<span v-else style="color:var(--sub);"> |
|
|
|
⚫ 未激活 |
|
|
|
</span> |
|
|
|
<div style="display:flex;align-items:center;gap:4px;"> |
|
|
|
<span v-if="c.is_active" style="color:#16a34a;font-weight:600;"> |
|
|
|
🟢 活跃 |
|
|
|
</span> |
|
|
|
<span v-else style="color:var(--sub);"> |
|
|
|
⚫ 未激活 |
|
|
|
</span> |
|
|
|
<!-- 健康指示器 --> |
|
|
|
<span v-if="c.is_active && healthMap[c.id]" |
|
|
|
:title="getHealthTooltip(c.id)" |
|
|
|
:style="{ cursor: 'help', color: healthMap[c.id].status === 'ONLINE' ? '#16a34a' : '#dc2626' }"> |
|
|
|
{{ healthMap[c.id].status === 'ONLINE' ? '●' : '●' }} |
|
|
|
<span v-if="healthMap[c.id].latencyMs" style="font-size:10px;">{{ healthMap[c.id].latencyMs }}ms</span> |
|
|
|
</span> |
|
|
|
<span v-else-if="c.is_active" style="color:#d1d5db;cursor:help;" title="未检测">●</span> |
|
|
|
</div> |
|
|
|
</td> |
|
|
|
<td> |
|
|
|
<button class="btn btn-sm btn-outline" @click="openEditModal(c)">编辑</button> |
|
|
|
<button v-if="!c.is_active" class="btn btn-sm btn-primary" @click="activate(c.id)">激活</button> |
|
|
|
<button v-if="!c.is_active" class="btn btn-sm btn-danger" @click="remove(c.id, c.name)">删除</button> |
|
|
|
<td style="white-space:nowrap;"> |
|
|
|
<button class="btn btn-sm btn-outline" @click="openEditModal(c)" title="编辑">✏️</button> |
|
|
|
<!-- F1: 测试连接 --> |
|
|
|
<button class="btn btn-sm btn-outline" @click="testConnection(c)" :disabled="testLoading[c.id]" title="测试连接"> |
|
|
|
{{ testLoading[c.id] ? '⏳' : '🔌' }} |
|
|
|
</button> |
|
|
|
<!-- F2: 复制 --> |
|
|
|
<button class="btn btn-sm btn-outline" @click="duplicateConfig(c)" title="复制">📋</button> |
|
|
|
<button v-if="!c.is_active" class="btn btn-sm btn-primary" @click="activate(c.id)" title="激活">▶️</button> |
|
|
|
<button v-if="c.is_active" class="btn btn-sm btn-outline" @click="deactivateConfig(c.id)" title="停用">⏸️</button> |
|
|
|
<button v-if="!c.is_active" class="btn btn-sm btn-danger" @click="remove(c.id, c.name)" title="删除">🗑️</button> |
|
|
|
</td> |
|
|
|
</tr> |
|
|
|
</tbody> |
|
|
|
@ -159,6 +279,18 @@ export default { |
|
|
|
</template> |
|
|
|
<button :disabled="currentPage >= totalPages" @click="load(currentPage + 1)">下一页</button> |
|
|
|
</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> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- 编辑/新建弹窗 --> |
|
|
|
@ -208,12 +340,26 @@ export default { |
|
|
|
<!-- 模型名称(跨列) --> |
|
|
|
<div class="model-form-full"> |
|
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">模型名称 <span style="color:#dc2626;">*</span></label> |
|
|
|
<!-- F5: 模型下拉项增加能力标签 --> |
|
|
|
<input v-if="!currentProviderModels.length" type="text" class="input" v-model="editModal.form.model_name" |
|
|
|
:placeholder="currentProviderDefaultModel || '请输入模型名称'"> |
|
|
|
<select v-else class="input" v-model="editModal.form.model_name"> |
|
|
|
<option v-for="m in currentProviderModels" :key="m" :value="m">{{ m }}</option> |
|
|
|
<select v-else class="input" v-model="editModal.form.model_name" @change="onModelChange"> |
|
|
|
<option v-for="m in currentProviderModels" :key="m.name" :value="m.name"> |
|
|
|
{{ m.name }} |
|
|
|
<template v-if="m.maxContextTokens"> ({{ formatTokens(m.maxContextTokens) }} ctx</template> |
|
|
|
<template v-if="m.maxOutputTokens"> · {{ formatTokens(m.maxOutputTokens) }} out</template> |
|
|
|
<template v-if="m.supportsVision"> · 👁</template> |
|
|
|
<template v-if="m.supportsFunctionCall"> · 🔧</template> |
|
|
|
<template v-if="m.maxContextTokens">)</template> |
|
|
|
</option> |
|
|
|
<option value="__custom__">自定义输入...</option> |
|
|
|
</select> |
|
|
|
<!-- F5: 选中模型的能力展示 --> |
|
|
|
<div v-if="selectedModelMeta" style="font-size:11px;color:#6b7280;margin-top:4px;"> |
|
|
|
📐 {{ formatTokens(selectedModelMeta.maxContextTokens) }} 上下文 · {{ formatTokens(selectedModelMeta.maxOutputTokens) }} 最大输出 |
|
|
|
<span v-if="selectedModelMeta.supportsVision"> · 👁 视觉</span> |
|
|
|
<span v-if="selectedModelMeta.supportsFunctionCall"> · 🔧 函数调用</span> |
|
|
|
</div> |
|
|
|
<input v-if="editModal.form.model_name === '__custom__'" type="text" class="input" |
|
|
|
v-model="customModelName" placeholder="输入自定义模型名称" style="margin-top:6px;"> |
|
|
|
</div> |
|
|
|
@ -224,8 +370,15 @@ export default { |
|
|
|
<input type="number" class="input" v-model.number="editModal.form.temperature" step="0.1" min="0" max="2" placeholder="0.7"> |
|
|
|
</div> |
|
|
|
<div> |
|
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">最大 Token 数</label> |
|
|
|
<input type="number" class="input" v-model.number="editModal.form.max_tokens" min="1" max="128000" placeholder="2000"> |
|
|
|
<!-- F5: maxTokens 上限动态变化 --> |
|
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">最大 Token 数 |
|
|
|
<span v-if="selectedModelMeta && selectedModelMeta.maxOutputTokens" style="font-weight:400;color:#6b7280;"> |
|
|
|
(上限 {{ formatTokens(selectedModelMeta.maxOutputTokens) }}) |
|
|
|
</span> |
|
|
|
</label> |
|
|
|
<input type="number" class="input" v-model.number="editModal.form.max_tokens" min="1" |
|
|
|
:max="selectedModelMeta && selectedModelMeta.maxOutputTokens ? selectedModelMeta.maxOutputTokens : 128000" |
|
|
|
placeholder="2000"> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- Base URL(跨列) --> |
|
|
|
@ -243,10 +396,42 @@ export default { |
|
|
|
<input type="number" class="input" v-model.number="editModal.form.embeddingDimensions" min="1" max="8192" placeholder="1024" style="max-width:240px;"> |
|
|
|
<div style="font-size:11px;color:#92400e;margin-top:4px;"> |
|
|
|
⚠️ 修改维度后需重建向量表:<code style="background:#fde68a;padding:1px 4px;border-radius:3px;">DROP TABLE IF EXISTS vector_store CASCADE</code>,然后重启服务并重新上传知识库文档。 |
|
|
|
常用维度:千问 text-embedding-v2=1024 | 豆包 doubao-embedding-text=2048 | OpenAI text-embedding-3-small=1536 |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- F3: 高级参数折叠区 --> |
|
|
|
<div class="model-form-full"> |
|
|
|
<details :open="advancedParamsOpen" style="border:1px solid #e5e7eb;border-radius:8px;padding:0;"> |
|
|
|
<summary @click.prevent="advancedParamsOpen = !advancedParamsOpen" |
|
|
|
style="padding:10px 14px;cursor:pointer;font-size:13px;font-weight:600;color:#374151;user-select:none;"> |
|
|
|
⚡ 高级生成参数 |
|
|
|
<span v-if="hasAdvancedParams" style="color:#2563eb;font-weight:400;font-size:11px;margin-left:4px;">(已配置)</span> |
|
|
|
</summary> |
|
|
|
<div style="padding:12px 14px;display:grid;grid-template-columns:1fr 1fr;gap:12px;"> |
|
|
|
<div> |
|
|
|
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Top P (核采样)</label> |
|
|
|
<input type="number" class="input" v-model.number="editModal.form.topP" step="0.01" min="0" max="1" placeholder="0.8"> |
|
|
|
</div> |
|
|
|
<div> |
|
|
|
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Top K</label> |
|
|
|
<input type="number" class="input" v-model.number="editModal.form.topK" step="1" min="1" max="100" placeholder="不限制"> |
|
|
|
</div> |
|
|
|
<div> |
|
|
|
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">频率惩罚 (Frequency Penalty)</label> |
|
|
|
<input type="number" class="input" v-model.number="editModal.form.frequencyPenalty" step="0.1" min="-2" max="2" placeholder="0"> |
|
|
|
</div> |
|
|
|
<div> |
|
|
|
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">存在惩罚 (Presence Penalty)</label> |
|
|
|
<input type="number" class="input" v-model.number="editModal.form.presencePenalty" step="0.1" min="-2" max="2" placeholder="0"> |
|
|
|
</div> |
|
|
|
<div style="grid-column:1/-1;"> |
|
|
|
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">停止词序列(逗号分隔,最多 4 个)</label> |
|
|
|
<input type="text" class="input" v-model="editModal.form.stopSequences" placeholder="如:END,STOP"> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</details> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- 优先级 + 是否激活(双列) --> |
|
|
|
<div> |
|
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">优先级</label> |
|
|
|
@ -255,7 +440,7 @@ export default { |
|
|
|
<div style="display:flex;align-items:flex-end;padding-bottom:4px;"> |
|
|
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;"> |
|
|
|
<input type="checkbox" v-model="editModal.form.is_active"> |
|
|
|
设为活跃配置(同类型只能有一个活跃) |
|
|
|
设为活跃配置(F6: 同类型支持多个活跃,按优先级 Fallback) |
|
|
|
</label> |
|
|
|
</div> |
|
|
|
|
|
|
|
@ -267,12 +452,58 @@ export default { |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- 按钮 --> |
|
|
|
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;"> |
|
|
|
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;flex-wrap:wrap;"> |
|
|
|
<button class="btn btn-outline" @click="closeEditModal">取消</button> |
|
|
|
<!-- F1: 弹窗内测试连接 --> |
|
|
|
<button v-if="editModal.mode === 'edit'" class="btn btn-outline" @click="testConnectionById(editModal.editId)" |
|
|
|
:disabled="testLoading[editModal.editId]" style="border-color:#2563eb;color:#2563eb;"> |
|
|
|
{{ testLoading[editModal.editId] ? '⏳ 测试中...' : '🔌 测试连接' }} |
|
|
|
</button> |
|
|
|
<button class="btn btn-primary" @click="saveConfig">💾 保存</button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- F7: 导入弹窗 --> |
|
|
|
<div class="modal-overlay" :class="{ active: showImportDialog }"> |
|
|
|
<div class="modal-box" style="max-width:600px;"> |
|
|
|
<button class="modal-close" @click="showImportDialog = false">×</button> |
|
|
|
<h2>📥 导入模型配置</h2> |
|
|
|
<div style="margin:16px 0;"> |
|
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;">选择 JSON 文件</label> |
|
|
|
<input type="file" accept=".json" @change="onImportFileSelect" class="input"> |
|
|
|
</div> |
|
|
|
<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 v-if="importPreview.configs.length > 5" style="font-size:11px;color:#6b7280;">... 还有 {{ importPreview.configs.length - 5 }} 条</div> |
|
|
|
</div> |
|
|
|
<div style="margin:12px 0;"> |
|
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;">冲突策略</label> |
|
|
|
<select class="input" v-model="importConflict" style="max-width:300px;"> |
|
|
|
<option value="skip">跳过同名配置</option> |
|
|
|
<option value="overwrite">覆盖同名配置</option> |
|
|
|
</select> |
|
|
|
</div> |
|
|
|
<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 v-if="importResult.errors?.length" style="margin-top:8px;"> |
|
|
|
<div v-for="(err, i) in importResult.errors" :key="i" style="font-size:11px;color:#dc2626;">⚠ {{ err }}</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;"> |
|
|
|
<button class="btn btn-outline" @click="showImportDialog = false">关闭</button> |
|
|
|
<button class="btn btn-primary" @click="doImport" :disabled="!importPreview">📥 确认导入</button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
`,
|
|
|
|
setup() { |
|
|
|
const configs = ref([]) |
|
|
|
@ -283,11 +514,32 @@ export default { |
|
|
|
const showApiKey = ref(false) |
|
|
|
const customModelName = ref('') |
|
|
|
const providerTip = ref('') |
|
|
|
const advancedParamsOpen = ref(false) |
|
|
|
|
|
|
|
// F4: 健康状态
|
|
|
|
const healthMap = ref({}) |
|
|
|
const healthSummary = ref(null) |
|
|
|
let healthTimer = null |
|
|
|
|
|
|
|
// 计算当前提供商的模型列表和默认值
|
|
|
|
// F5: 当前提供商的模型列表和默认值
|
|
|
|
const currentProviderModels = ref([]) |
|
|
|
const currentProviderDefaultModel = ref('') |
|
|
|
const providerBaseUrlPlaceholder = ref('') |
|
|
|
const selectedModelMeta = ref(null) |
|
|
|
|
|
|
|
// F1: 测试状态
|
|
|
|
const testLoading = ref({}) |
|
|
|
const testResult = ref({ visible: false, success: false, latencyMs: 0 }) |
|
|
|
let testResultTimer = null |
|
|
|
|
|
|
|
// F6: Fallback 链
|
|
|
|
const fallbackChains = ref([]) |
|
|
|
|
|
|
|
// F7: 导入导出
|
|
|
|
const showImportDialog = ref(false) |
|
|
|
const importPreview = ref(null) |
|
|
|
const importConflict = ref('skip') |
|
|
|
const importResult = ref(null) |
|
|
|
|
|
|
|
// 编辑弹窗状态
|
|
|
|
const editModal = ref({ |
|
|
|
@ -308,44 +560,74 @@ export default { |
|
|
|
max_tokens: 2000, |
|
|
|
base_url: '', |
|
|
|
embeddingDimensions: 1024, |
|
|
|
// F3: 高级参数
|
|
|
|
topP: null, |
|
|
|
topK: null, |
|
|
|
frequencyPenalty: null, |
|
|
|
presencePenalty: null, |
|
|
|
stopSequences: '', |
|
|
|
// 基础字段
|
|
|
|
priority: 0, |
|
|
|
is_active: false, |
|
|
|
description: '' |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 提供商切换时自动填充(按 app_type 区分对话/向量模型)
|
|
|
|
// F3: 检查是否配置了高级参数
|
|
|
|
const hasAdvancedParams = computed(() => { |
|
|
|
const f = editModal.value.form |
|
|
|
return f.topP != null || f.topK != null || f.frequencyPenalty != null || |
|
|
|
f.presencePenalty != null || (f.stopSequences && f.stopSequences.trim()) |
|
|
|
}) |
|
|
|
|
|
|
|
// 提供商切换时自动填充
|
|
|
|
function onProviderChange() { |
|
|
|
const provider = editModal.value.form.provider |
|
|
|
const defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.other |
|
|
|
const isEmbedding = editModal.value.form.app_type === 'EMBEDDING' |
|
|
|
|
|
|
|
// 仅在新建模式下自动填充 Base URL,编辑模式保留用户已配置的值
|
|
|
|
if (editModal.value.mode !== 'edit') { |
|
|
|
editModal.value.form.base_url = defaults.baseUrl |
|
|
|
} |
|
|
|
|
|
|
|
// 按 app_type 选择模型列表和默认模型
|
|
|
|
const models = isEmbedding ? (defaults.embeddingModels || []) : (defaults.chatModels || []) |
|
|
|
const defaultModel = isEmbedding ? (defaults.defaultEmbeddingModel || '') : (defaults.defaultChatModel || '') |
|
|
|
|
|
|
|
// 仅新建模式且模型名为空时自动填充默认模型
|
|
|
|
if (editModal.value.mode === 'add' && !editModal.value.form.model_name) { |
|
|
|
editModal.value.form.model_name = defaultModel |
|
|
|
} |
|
|
|
|
|
|
|
// F5: 使用对象格式的模型列表
|
|
|
|
currentProviderModels.value = models |
|
|
|
currentProviderDefaultModel.value = defaultModel |
|
|
|
providerTip.value = defaults.tip || '' |
|
|
|
providerBaseUrlPlaceholder.value = defaults.baseUrl || '私有化部署时可填写自定义地址' |
|
|
|
customModelName.value = '' |
|
|
|
onModelChange() |
|
|
|
} |
|
|
|
|
|
|
|
// 监听 app_type 变化,刷新模型列表
|
|
|
|
watch(() => editModal.value.form.app_type, () => { |
|
|
|
if (editModal.value.visible) { |
|
|
|
onProviderChange() |
|
|
|
// F5: 模型切换时更新能力元信息
|
|
|
|
function onModelChange() { |
|
|
|
const modelName = editModal.value.form.model_name |
|
|
|
if (modelName === '__custom__') { |
|
|
|
selectedModelMeta.value = null |
|
|
|
return |
|
|
|
} |
|
|
|
const found = currentProviderModels.value.find(m => m.name === modelName) |
|
|
|
selectedModelMeta.value = found || null |
|
|
|
} |
|
|
|
|
|
|
|
// F5: 获取列表中某个配置对应模型的能力元信息
|
|
|
|
function getModelMeta(config) { |
|
|
|
const provider = config.provider |
|
|
|
const defaults = PROVIDER_DEFAULTS[provider] |
|
|
|
if (!defaults) return null |
|
|
|
const allModels = [...(defaults.chatModels || []), ...(defaults.embeddingModels || [])] |
|
|
|
return allModels.find(m => m.name === config.model_name) || null |
|
|
|
} |
|
|
|
|
|
|
|
watch(() => editModal.value.form.app_type, () => { |
|
|
|
if (editModal.value.visible) onProviderChange() |
|
|
|
}) |
|
|
|
|
|
|
|
// ==================== 数据加载 ====================
|
|
|
|
@ -358,6 +640,10 @@ export default { |
|
|
|
configs.value = json.data || [] |
|
|
|
total.value = json.total || 0 |
|
|
|
totalPages.value = json.pages || 1 |
|
|
|
// 加载后刷新健康状态
|
|
|
|
loadHealthStatus() |
|
|
|
// 构建 Fallback 链
|
|
|
|
buildFallbackChains() |
|
|
|
} else { |
|
|
|
toast(json.message || '查询失败', 'error') |
|
|
|
} |
|
|
|
@ -366,6 +652,75 @@ export default { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== F4: 健康状态 ====================
|
|
|
|
|
|
|
|
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 (e) { |
|
|
|
// 静默失败
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function getHealthTooltip(configId) { |
|
|
|
const h = healthMap.value[configId] |
|
|
|
if (!h) return '未检测' |
|
|
|
let tip = h.status === 'ONLINE' ? '在线' : '离线' |
|
|
|
if (h.latencyMs) tip += ` · 延迟 ${h.latencyMs}ms` |
|
|
|
if (h.lastCheckTime) tip += ` · 最后检测 ${new Date(h.lastCheckTime).toLocaleTimeString()}` |
|
|
|
if (h.errorMessage) tip += ` · ${h.errorMessage}` |
|
|
|
return tip |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== F6: Fallback 链 ====================
|
|
|
|
|
|
|
|
function buildFallbackChains() { |
|
|
|
const chains = [] |
|
|
|
const appTypes = ['CHAT', 'EMBEDDING', 'RAG_REWRITE', 'RERANK'] |
|
|
|
for (const appType of appTypes) { |
|
|
|
const activeConfigs = configs.value |
|
|
|
.filter(c => c.app_type === appType && c.is_active) |
|
|
|
.sort((a, b) => (b.priority || 0) - (a.priority || 0)) |
|
|
|
if (activeConfigs.length > 0) { |
|
|
|
chains.push({ appType, configs: activeConfigs }) |
|
|
|
} |
|
|
|
} |
|
|
|
fallbackChains.value = chains |
|
|
|
} |
|
|
|
|
|
|
|
// F6: 拖拽排序
|
|
|
|
let dragItem = null |
|
|
|
let dragChain = null |
|
|
|
|
|
|
|
function onDragStart(e, item, chain) { |
|
|
|
dragItem = item |
|
|
|
dragChain = chain |
|
|
|
e.dataTransfer.effectAllowed = 'move' |
|
|
|
} |
|
|
|
|
|
|
|
async function onDrop(e, targetItem, chain) { |
|
|
|
if (!dragItem || dragItem.id === targetItem.id) return |
|
|
|
// 交换优先级
|
|
|
|
const fromPriority = dragItem.priority || 0 |
|
|
|
const toPriority = targetItem.priority || 0 |
|
|
|
try { |
|
|
|
await api.updateModelConfigPriority(dragItem.id, toPriority) |
|
|
|
await api.updateModelConfigPriority(targetItem.id, fromPriority) |
|
|
|
toast('优先级已调整', 'success') |
|
|
|
load(currentPage.value) |
|
|
|
} catch (err) { |
|
|
|
toast('调整失败:' + err.message, 'error') |
|
|
|
} |
|
|
|
dragItem = null |
|
|
|
dragChain = null |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 弹窗操作 ====================
|
|
|
|
|
|
|
|
function openAddModal() { |
|
|
|
@ -376,11 +731,11 @@ export default { |
|
|
|
form: createEmptyForm() |
|
|
|
} |
|
|
|
showApiKey.value = false |
|
|
|
advancedParamsOpen.value = false |
|
|
|
onProviderChange() |
|
|
|
} |
|
|
|
|
|
|
|
function openEditModal(config) { |
|
|
|
// 从 extraConfig 中读取 embeddingDimensions
|
|
|
|
let extraConfig = {} |
|
|
|
if (config.extraConfig) { |
|
|
|
try { |
|
|
|
@ -401,12 +756,20 @@ export default { |
|
|
|
max_tokens: config.max_tokens, |
|
|
|
base_url: config.base_url || '', |
|
|
|
embeddingDimensions: extraConfig.dimensions || 1024, |
|
|
|
// F3: 高级参数
|
|
|
|
topP: extraConfig.topP ?? null, |
|
|
|
topK: extraConfig.topK ?? null, |
|
|
|
frequencyPenalty: extraConfig.frequencyPenalty ?? null, |
|
|
|
presencePenalty: extraConfig.presencePenalty ?? null, |
|
|
|
stopSequences: extraConfig.stopSequences || '', |
|
|
|
// 基础字段
|
|
|
|
priority: config.priority || 0, |
|
|
|
is_active: config.is_active || false, |
|
|
|
description: config.description || '' |
|
|
|
} |
|
|
|
} |
|
|
|
showApiKey.value = false |
|
|
|
advancedParamsOpen.value = false |
|
|
|
onProviderChange() |
|
|
|
} |
|
|
|
|
|
|
|
@ -416,10 +779,6 @@ export default { |
|
|
|
|
|
|
|
// ==================== 保存配置 ====================
|
|
|
|
|
|
|
|
/** |
|
|
|
* 将前端表单字段名(snake_case)转换为后端 Java 实体字段名(camelCase) |
|
|
|
* 因为 Jackson 默认使用 camelCase 反序列化,前端发送 snake_case 会导致字段无法映射 |
|
|
|
*/ |
|
|
|
function toCamelCase(form) { |
|
|
|
const data = { |
|
|
|
name: form.name, |
|
|
|
@ -434,11 +793,20 @@ export default { |
|
|
|
isActive: form.is_active, |
|
|
|
description: form.description |
|
|
|
} |
|
|
|
// EMBEDDING 类型:将向量维度写入 extraConfig
|
|
|
|
// 构建 extraConfig
|
|
|
|
const extraConfig = {} |
|
|
|
if (form.app_type === 'EMBEDDING') { |
|
|
|
data.extraConfig = { |
|
|
|
dimensions: form.embeddingDimensions || 1024 |
|
|
|
} |
|
|
|
extraConfig.dimensions = form.embeddingDimensions || 1024 |
|
|
|
} |
|
|
|
// F3: 高级参数写入 extraConfig
|
|
|
|
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 && form.stopSequences.trim()) extraConfig.stopSequences = form.stopSequences.trim() |
|
|
|
|
|
|
|
if (Object.keys(extraConfig).length > 0) { |
|
|
|
data.extraConfig = extraConfig |
|
|
|
} |
|
|
|
return data |
|
|
|
} |
|
|
|
@ -446,7 +814,6 @@ export default { |
|
|
|
async function saveConfig() { |
|
|
|
const form = editModal.value.form |
|
|
|
|
|
|
|
// 如果选了"自定义输入",取自定义名称
|
|
|
|
if (form.model_name === '__custom__') { |
|
|
|
if (!customModelName.value.trim()) { |
|
|
|
toast('请输入自定义模型名称', 'error') |
|
|
|
@ -474,12 +841,10 @@ export default { |
|
|
|
|
|
|
|
try { |
|
|
|
let json |
|
|
|
// 转换为 camelCase 后再发送,确保 Jackson 正确反序列化
|
|
|
|
const camelData = toCamelCase(form) |
|
|
|
if (editModal.value.mode === 'add') { |
|
|
|
json = await api.createModelConfig(camelData) |
|
|
|
} else { |
|
|
|
// 编辑模式:API Key 留空则不发送(不覆盖原值)
|
|
|
|
if (!camelData.apiKey || !camelData.apiKey.trim()) { |
|
|
|
delete camelData.apiKey |
|
|
|
} |
|
|
|
@ -487,7 +852,7 @@ export default { |
|
|
|
} |
|
|
|
|
|
|
|
if (json.success) { |
|
|
|
toast(editModal.value.mode === 'add' ? '配置创建成功,已切换生效' : '配置更新成功,已切换生效', 'success') |
|
|
|
toast(editModal.value.mode === 'add' ? '配置创建成功' : '配置更新成功', 'success') |
|
|
|
closeEditModal() |
|
|
|
load(currentPage.value) |
|
|
|
} else { |
|
|
|
@ -498,10 +863,67 @@ export default { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 激活配置 ====================
|
|
|
|
// ==================== F1: 测试连接 ====================
|
|
|
|
|
|
|
|
async function testConnection(config) { |
|
|
|
testLoading.value = { ...testLoading.value, [config.id]: true } |
|
|
|
try { |
|
|
|
const json = await api.testModelConfig(config.id) |
|
|
|
showTestResult(json) |
|
|
|
} catch (e) { |
|
|
|
showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) |
|
|
|
} finally { |
|
|
|
testLoading.value = { ...testLoading.value, [config.id]: false } |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function testConnectionById(id) { |
|
|
|
testLoading.value = { ...testLoading.value, [id]: true } |
|
|
|
try { |
|
|
|
const json = await api.testModelConfig(id) |
|
|
|
showTestResult(json) |
|
|
|
} catch (e) { |
|
|
|
showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) |
|
|
|
} finally { |
|
|
|
testLoading.value = { ...testLoading.value, [id]: false } |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function showTestResult(result) { |
|
|
|
// 清除上一次的定时器,防止多次点击导致定时器泄漏
|
|
|
|
if (testResultTimer) clearTimeout(testResultTimer) |
|
|
|
testResult.value = { |
|
|
|
visible: true, |
|
|
|
success: result.success, |
|
|
|
latencyMs: result.latencyMs || 0, |
|
|
|
dimensions: result.dimensions || null, |
|
|
|
errorMessage: result.errorMessage || result.message || null |
|
|
|
} |
|
|
|
// 5 秒后自动隐藏
|
|
|
|
testResultTimer = setTimeout(() => { testResult.value.visible = false }, 5000) |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== F2: 配置复制 ====================
|
|
|
|
|
|
|
|
async function duplicateConfig(config) { |
|
|
|
if (!confirm('确定复制配置「' + (config.name || config.id) + '」?')) return |
|
|
|
try { |
|
|
|
const json = await api.duplicateModelConfig(config.id) |
|
|
|
if (json.success) { |
|
|
|
toast('配置复制成功', 'success') |
|
|
|
load(currentPage.value) |
|
|
|
} else { |
|
|
|
toast(json.message || '复制失败', 'error') |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
toast('复制失败:' + e.message, 'error') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 激活/停用 ====================
|
|
|
|
|
|
|
|
async function activate(id) { |
|
|
|
if (!confirm('确定激活此配置?同类型的其他配置将被自动停用,新配置将立即生效。')) return |
|
|
|
if (!confirm('确定激活此配置?新配置将立即生效。')) return |
|
|
|
try { |
|
|
|
const json = await api.activateModelConfig(id) |
|
|
|
if (json.success) { |
|
|
|
@ -515,6 +937,21 @@ export default { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function deactivateConfig(id) { |
|
|
|
if (!confirm('确定停用此配置?将从 Fallback 链中移除。')) return |
|
|
|
try { |
|
|
|
const json = await api.deactivateModelConfig(id) |
|
|
|
if (json.success) { |
|
|
|
toast('配置已停用', 'success') |
|
|
|
load(currentPage.value) |
|
|
|
} else { |
|
|
|
toast(json.message || '停用失败', 'error') |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
toast('停用失败:' + e.message, 'error') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 删除配置 ====================
|
|
|
|
|
|
|
|
async function remove(id, name) { |
|
|
|
@ -532,15 +969,75 @@ export default { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== F7: 导入/导出 ====================
|
|
|
|
|
|
|
|
async function exportConfigs() { |
|
|
|
try { |
|
|
|
const json = await api.exportModelConfigs(true) |
|
|
|
if (json.success) { |
|
|
|
const blob = new Blob([JSON.stringify(json.data, null, 2)], { type: 'application/json' }) |
|
|
|
const url = URL.createObjectURL(blob) |
|
|
|
const a = document.createElement('a') |
|
|
|
a.href = url |
|
|
|
a.download = 'model-configs-' + new Date().toISOString().slice(0, 10) + '.json' |
|
|
|
a.click() |
|
|
|
URL.revokeObjectURL(url) |
|
|
|
toast('导出成功:' + json.total + ' 条配置', 'success') |
|
|
|
} else { |
|
|
|
toast(json.message || '导出失败', 'error') |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
toast('导出失败:' + e.message, 'error') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function onImportFileSelect(e) { |
|
|
|
const file = e.target.files[0] |
|
|
|
if (!file) return |
|
|
|
const reader = new FileReader() |
|
|
|
reader.onload = (ev) => { |
|
|
|
try { |
|
|
|
let data = JSON.parse(ev.target.result) |
|
|
|
// 兼容:如果是 {data: [...]} 格式,取出 data 数组
|
|
|
|
if (!Array.isArray(data) && data.data) data = data.data |
|
|
|
if (!Array.isArray(data)) { |
|
|
|
toast('JSON 格式不正确,需要数组格式', 'error') |
|
|
|
return |
|
|
|
} |
|
|
|
importPreview.value = { configs: data } |
|
|
|
importResult.value = null |
|
|
|
} catch (err) { |
|
|
|
toast('文件解析失败:' + err.message, 'error') |
|
|
|
} |
|
|
|
} |
|
|
|
reader.readAsText(file) |
|
|
|
} |
|
|
|
|
|
|
|
async function doImport() { |
|
|
|
if (!importPreview.value) return |
|
|
|
try { |
|
|
|
const json = await api.importModelConfigs(importPreview.value.configs, importConflict.value) |
|
|
|
if (json.success) { |
|
|
|
importResult.value = json.data |
|
|
|
toast('导入完成', 'success') |
|
|
|
load(currentPage.value) |
|
|
|
} else { |
|
|
|
toast(json.message || '导入失败', 'error') |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
toast('导入失败:' + e.message, 'error') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 工具函数 ====================
|
|
|
|
|
|
|
|
function getAppTypeLabel(appType) { |
|
|
|
const map = { CHAT: '智能客服对话', EMBEDDING: '文本向量化', RAG_REWRITE: 'RAG查询重写' } |
|
|
|
const map = { CHAT: '智能客服对话', EMBEDDING: '文本向量化', RAG_REWRITE: 'RAG查询重写', RERANK: '重排序' } |
|
|
|
return map[appType] || appType |
|
|
|
} |
|
|
|
|
|
|
|
function getAppTypeBadgeClass(appType) { |
|
|
|
const map = { CHAT: 'badge-get', EMBEDDING: '', RAG_REWRITE: '' } |
|
|
|
const map = { CHAT: 'badge-get', EMBEDDING: '', RAG_REWRITE: '', RERANK: '' } |
|
|
|
return map[appType] || '' |
|
|
|
} |
|
|
|
|
|
|
|
@ -549,13 +1046,38 @@ export default { |
|
|
|
return found ? found.label : provider |
|
|
|
} |
|
|
|
|
|
|
|
// 初始加载
|
|
|
|
onMounted(() => { load() }) |
|
|
|
// ==================== 生命周期 ====================
|
|
|
|
|
|
|
|
onMounted(() => { |
|
|
|
load() |
|
|
|
// F4: 30s 自动轮询刷新健康状态
|
|
|
|
healthTimer = setInterval(loadHealthStatus, 30000) |
|
|
|
}) |
|
|
|
|
|
|
|
onUnmounted(() => { |
|
|
|
if (healthTimer) clearInterval(healthTimer) |
|
|
|
if (testResultTimer) clearTimeout(testResultTimer) |
|
|
|
}) |
|
|
|
|
|
|
|
return { |
|
|
|
configs, currentPage, totalPages, total, filterAppType, showApiKey, editModal, |
|
|
|
appTypeOptions: APP_TYPE_OPTIONS, providerOptions: PROVIDER_OPTIONS, |
|
|
|
providerTip, currentProviderModels, currentProviderDefaultModel, providerBaseUrlPlaceholder, customModelName, |
|
|
|
providerTip, currentProviderModels, currentProviderDefaultModel, providerBaseUrlPlaceholder, |
|
|
|
customModelName, advancedParamsOpen, hasAdvancedParams, |
|
|
|
// F1
|
|
|
|
testLoading, testResult, testConnection, testConnectionById, |
|
|
|
// F2
|
|
|
|
duplicateConfig, |
|
|
|
// F4
|
|
|
|
healthMap, healthSummary, getHealthTooltip, |
|
|
|
// F5
|
|
|
|
selectedModelMeta, getModelMeta, onModelChange, formatTokens, |
|
|
|
// F6
|
|
|
|
fallbackChains, onDragStart, onDrop, deactivateConfig, |
|
|
|
// F7
|
|
|
|
showImportDialog, importPreview, importConflict, importResult, |
|
|
|
onImportFileSelect, doImport, exportConfigs, |
|
|
|
// 基础
|
|
|
|
load, openAddModal, openEditModal, closeEditModal, saveConfig, activate, remove, |
|
|
|
onProviderChange, getAppTypeLabel, getAppTypeBadgeClass, getProviderLabel, formatDate |
|
|
|
} |
|
|
|
|