1240 lines
36 KiB
Vue
1240 lines
36 KiB
Vue
<template>
|
||
<div class="model-config-page">
|
||
<section class="page-head">
|
||
<div>
|
||
<h1>模型配置</h1>
|
||
<p>统一维护供应商 Key、Base URL 与可用模型,供 RAG、面试出题、对练调用。</p>
|
||
</div>
|
||
<div class="head-actions">
|
||
<el-button type="danger" :icon="Plus" @click="openProviderDialog()">新增供应商</el-button>
|
||
<el-button type="danger" :icon="Plus" @click="openModelDialog()">新增模型</el-button>
|
||
<el-button :icon="Connection" :loading="testing" @click="runConnectionTest">测试连接</el-button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="model-layout">
|
||
<aside class="panel provider-panel">
|
||
<div class="panel-title">
|
||
<h2>供应商</h2>
|
||
</div>
|
||
<el-input v-model="keyword" class="provider-search" placeholder="搜索供应商名称或编码" :prefix-icon="Search" clearable />
|
||
|
||
<div v-loading="loading" class="provider-list">
|
||
<button
|
||
v-for="provider in filteredProviders"
|
||
:key="provider.providerCode"
|
||
class="provider-item"
|
||
:class="{ active: provider.providerCode === selectedProviderCode }"
|
||
@click="selectedProviderCode = provider.providerCode"
|
||
>
|
||
<span class="provider-logo" :class="`logo-${provider.providerCode}`">{{ provider.logo }}</span>
|
||
<span class="provider-text">
|
||
<strong>{{ provider.providerName }}</strong>
|
||
<small>{{ provider.providerCode }}</small>
|
||
</span>
|
||
<span class="status-tag" :class="providerTag(provider).className">{{ providerTag(provider).label }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<footer>共 {{ providers.length }} 个供应商</footer>
|
||
</aside>
|
||
|
||
<main class="detail-stack">
|
||
<section class="panel detail-panel">
|
||
<div class="panel-title with-actions">
|
||
<h2>供应商详情</h2>
|
||
<div>
|
||
<el-button :icon="EditPen" @click="openProviderDialog(activeProvider)">编辑</el-button>
|
||
<el-button type="danger" plain :icon="Connection" :loading="testing" @click="runConnectionTest">测试</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-grid">
|
||
<div class="field-list">
|
||
<div class="field-row">
|
||
<span>供应商名称</span>
|
||
<strong>{{ activeProvider.providerName }}</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>供应商编码</span>
|
||
<strong>{{ activeProvider.providerCode }}</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>Base URL</span>
|
||
<strong>{{ activeProvider.apiHost }}</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>API Key</span>
|
||
<strong>
|
||
{{ keyText }}
|
||
<el-button link :icon="showKey ? View : Hide" @click="showKey = !showKey" />
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field-list side">
|
||
<div class="field-row">
|
||
<span>状态</span>
|
||
<el-switch
|
||
:model-value="activeProvider.status === '0'"
|
||
active-color="#e60012"
|
||
inactive-color="#d7dee8"
|
||
@change="(value) => toggleProviderStatus(Boolean(value))"
|
||
/>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>最近测试时间</span>
|
||
<strong>{{ lastTestAt }}</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>请求方式</span>
|
||
<strong>OpenAI Compatible</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>备注</span>
|
||
<strong>{{ activeProvider.providerDesc || `${activeProvider.providerName} OpenAI 兼容接口` }}</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel vector-panel" v-loading="vectorLoading">
|
||
<div class="panel-title with-actions">
|
||
<h2>向量库状态</h2>
|
||
<div>
|
||
<el-button :icon="Refresh" @click="loadVectorStatus">刷新</el-button>
|
||
<el-button type="danger" plain :icon="Refresh" :loading="rebuilding" @click="runVectorRebuild">重建索引</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vector-grid">
|
||
<div class="field-row">
|
||
<span>启用模型</span>
|
||
<strong>{{ vectorStatus?.modelName || '-' }}</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>模型维度</span>
|
||
<strong>{{ vectorStatus?.modelDimension || '-' }}</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>Qdrant 维度</span>
|
||
<strong>{{ vectorStatus?.qdrantDimension || '-' }}</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>点数</span>
|
||
<strong>{{ vectorStatus?.qdrantPoints ?? '-' }}</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>片段向量</span>
|
||
<strong>{{ vectorStatus ? `${vectorStatus.embeddedFragments} / ${vectorStatus.fragments}` : '-' }}</strong>
|
||
</div>
|
||
<div class="field-row">
|
||
<span>当前状态</span>
|
||
<strong>
|
||
<el-tag :type="vectorStatus?.matched ? 'success' : 'warning'">{{ vectorStatusLabel }}</el-tag>
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel model-panel">
|
||
<div class="panel-title">
|
||
<h2>模型列表</h2>
|
||
</div>
|
||
<el-table :data="modelRows" class="model-table" size="large">
|
||
<el-table-column prop="category" label="类型" width="130" />
|
||
<el-table-column prop="modelName" label="模型名" min-width="190" />
|
||
<el-table-column prop="modelDescribe" label="用途" min-width="280" />
|
||
<el-table-column label="维度" width="110">
|
||
<template #default="{ row }">{{ row.modelDimension || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="启用" width="110">
|
||
<template #default="{ row }">
|
||
<el-switch
|
||
v-model="row.enabled"
|
||
active-color="#e60012"
|
||
inactive-color="#d7dee8"
|
||
@change="(value) => toggleModelEnabled(row, Boolean(value))"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="110">
|
||
<template #default="{ row }">
|
||
<el-button link type="danger" @click="openModelDialog(row)">编辑</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
<section class="panel prompt-panel" v-loading="promptLoading">
|
||
<div class="panel-title with-actions">
|
||
<h2>提示词模板</h2>
|
||
<div>
|
||
<el-button :icon="Refresh" @click="loadPromptTemplates">刷新</el-button>
|
||
<el-button type="danger" :icon="Plus" @click="openPromptDialog()">新增模板</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="promptTemplates" class="model-table" size="large">
|
||
<el-table-column prop="code" label="代码" min-width="150" />
|
||
<el-table-column prop="name" label="名称" min-width="160" />
|
||
<el-table-column prop="category" label="分类" width="120" />
|
||
<el-table-column prop="outputFormat" label="文体" width="120" />
|
||
<el-table-column prop="version" label="版本" width="100" />
|
||
<el-table-column label="启用" width="100">
|
||
<template #default="{ row }">
|
||
<el-switch
|
||
v-model="row.enabled"
|
||
active-color="#e60012"
|
||
inactive-color="#d7dee8"
|
||
@change="(value) => togglePromptEnabled(row, Boolean(value))"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="100">
|
||
<template #default="{ row }">
|
||
<el-button link type="danger" @click="openPromptDialog(row)">编辑</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
<div class="tip-bar">
|
||
<el-icon><InfoFilled /></el-icon>
|
||
<span>调用优先级:模型配置覆盖供应商默认值;未配置时使用内置默认兜底,接口不返回 API Key。</span>
|
||
</div>
|
||
</main>
|
||
</section>
|
||
|
||
<el-dialog v-model="providerDialogVisible" :title="providerForm.id > 0 ? '编辑供应商' : '新增供应商'" width="560px">
|
||
<el-form label-width="104px">
|
||
<el-form-item label="供应商名称" required>
|
||
<el-input v-model="providerForm.providerName" placeholder="DeepSeek" />
|
||
</el-form-item>
|
||
<el-form-item label="供应商编码" required>
|
||
<el-input v-model="providerForm.providerCode" placeholder="deepseek" :disabled="providerForm.id > 0" />
|
||
</el-form-item>
|
||
<el-form-item label="Base URL">
|
||
<el-input v-model="providerForm.apiHost" placeholder="https://api.deepseek.com" />
|
||
</el-form-item>
|
||
<el-form-item label="API Key">
|
||
<el-input v-model="providerForm.apiKey" type="password" show-password placeholder="留空则保留原密钥" />
|
||
</el-form-item>
|
||
<el-form-item label="描述">
|
||
<el-input v-model="providerForm.providerDesc" type="textarea" :rows="2" placeholder="OpenAI 兼容接口" />
|
||
</el-form-item>
|
||
<el-form-item label="状态">
|
||
<el-switch v-model="providerEnabled" active-text="启用" inactive-text="停用" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="providerDialogVisible = false">取消</el-button>
|
||
<el-button type="danger" :loading="saving" @click="submitProvider">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="modelDialogVisible" :title="modelForm.id > 0 ? '编辑模型' : '新增模型'" width="560px">
|
||
<el-form label-width="104px">
|
||
<el-form-item label="模型类型" required>
|
||
<el-select v-model="modelForm.category">
|
||
<el-option label="对话 chat" value="chat" />
|
||
<el-option label="向量 vector" value="vector" />
|
||
<el-option label="重排 rerank" value="rerank" />
|
||
<el-option label="语音识别 asr" value="asr" />
|
||
<el-option label="语音合成 tts" value="tts" />
|
||
<el-option label="视觉/OCR vision" value="vision" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="模型名称" required>
|
||
<el-input v-model="modelForm.modelName" placeholder="deepseek-chat" />
|
||
</el-form-item>
|
||
<el-form-item label="供应商" required>
|
||
<el-select v-model="modelForm.providerCode">
|
||
<el-option v-for="provider in providers" :key="provider.providerCode" :label="provider.providerName" :value="provider.providerCode" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="用途">
|
||
<el-input v-model="modelForm.modelDescribe" placeholder="用于通用对话与 RAG 生成" />
|
||
</el-form-item>
|
||
<el-form-item label="维度">
|
||
<el-input-number v-model="modelForm.modelDimension" :min="0" :controls="false" placeholder="向量模型填写" />
|
||
</el-form-item>
|
||
<el-form-item label="覆盖 URL">
|
||
<el-input v-model="modelForm.apiHost" placeholder="留空则使用供应商 Base URL" />
|
||
</el-form-item>
|
||
<el-form-item label="覆盖 Key">
|
||
<el-input v-model="modelForm.apiKey" type="password" show-password placeholder="留空则使用供应商 Key 或保留原值" />
|
||
</el-form-item>
|
||
<el-form-item label="启用">
|
||
<el-switch v-model="modelForm.enabled" active-text="启用" inactive-text="停用" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="modelDialogVisible = false">取消</el-button>
|
||
<el-button type="danger" :loading="saving" @click="submitModel">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="promptDialogVisible" :title="promptForm.id > 0 ? '编辑提示词模板' : '新增提示词模板'" width="720px">
|
||
<el-form label-width="104px">
|
||
<el-form-item label="模板代码" required>
|
||
<el-input v-model="promptForm.code" placeholder="summary_card" />
|
||
</el-form-item>
|
||
<el-form-item label="模板名称" required>
|
||
<el-input v-model="promptForm.name" placeholder="总结卡" />
|
||
</el-form-item>
|
||
<el-form-item label="分类">
|
||
<el-input v-model="promptForm.category" placeholder="practice" />
|
||
</el-form-item>
|
||
<el-form-item label="输出文体">
|
||
<el-input v-model="promptForm.outputFormat" placeholder="summary_card" />
|
||
</el-form-item>
|
||
<el-form-item label="系统提示词">
|
||
<el-input v-model="promptForm.systemPrompt" type="textarea" :rows="3" />
|
||
</el-form-item>
|
||
<el-form-item label="提示词模板" required>
|
||
<el-input v-model="promptForm.template" type="textarea" :rows="5" />
|
||
</el-form-item>
|
||
<el-form-item label="变量">
|
||
<el-input v-model="promptForm.variables" placeholder='["question","answer"]' />
|
||
</el-form-item>
|
||
<el-form-item label="版本">
|
||
<el-input v-model="promptForm.version" placeholder="v1" />
|
||
</el-form-item>
|
||
<el-form-item label="描述">
|
||
<el-input v-model="promptForm.description" type="textarea" :rows="2" />
|
||
</el-form-item>
|
||
<el-form-item label="启用">
|
||
<el-switch v-model="promptForm.enabled" active-text="启用" inactive-text="停用" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="promptDialogVisible = false">取消</el-button>
|
||
<el-button type="danger" :loading="promptSaving" @click="submitPromptTemplate">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup name="SystemModelConfig" lang="ts">
|
||
import { Connection, EditPen, Hide, InfoFilled, Plus, Refresh, Search, View } from '@element-plus/icons-vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import { computed, onMounted, reactive, ref } from 'vue';
|
||
import {
|
||
createModelConfig,
|
||
createModelProvider,
|
||
getModelConfigs,
|
||
getModelProviders,
|
||
getVectorIndexStatus,
|
||
rebuildVectorIndex,
|
||
testModelChat,
|
||
updateModelConfig,
|
||
updateModelConfigEnabled,
|
||
updateModelProvider,
|
||
updateModelProviderStatus,
|
||
type ModelConfig,
|
||
type ModelConfigPayload,
|
||
type ModelProvider,
|
||
type ModelProviderPayload,
|
||
type VectorIndexStatus
|
||
} from '@/api/aihr/model';
|
||
import {
|
||
createPromptTemplate,
|
||
listPromptTemplates,
|
||
updatePromptTemplate,
|
||
type PromptTemplateRequest,
|
||
type PromptTemplateResponse
|
||
} from '@/api/aihr/practice';
|
||
|
||
type ProviderView = ModelProvider & { logo: string };
|
||
type ProviderFormState = ModelProviderPayload & {
|
||
id: number;
|
||
originalProviderCode: string;
|
||
apiHost: string;
|
||
apiKey: string;
|
||
providerDesc: string;
|
||
sortOrder: number;
|
||
};
|
||
type ModelFormState = ModelConfigPayload & {
|
||
id: number;
|
||
apiHost: string;
|
||
apiKey: string;
|
||
modelDescribe: string;
|
||
};
|
||
type PromptTemplateFormState = PromptTemplateRequest & {
|
||
id: number;
|
||
};
|
||
|
||
const seedProviders: ProviderView[] = [
|
||
{
|
||
id: -2,
|
||
providerName: 'DeepSeek',
|
||
providerCode: 'deepseek',
|
||
providerDesc: 'DeepSeek 官方 API',
|
||
apiHost: 'https://api.deepseek.com',
|
||
status: '0',
|
||
sortOrder: 0,
|
||
source: 'seed',
|
||
configured: false,
|
||
logo: 'DS'
|
||
},
|
||
{
|
||
id: -3,
|
||
providerName: '智谱AI',
|
||
providerCode: 'zhipu',
|
||
providerDesc: '智谱 AI 大模型服务',
|
||
apiHost: 'https://open.bigmodel.cn',
|
||
status: '0',
|
||
sortOrder: 1,
|
||
source: 'seed',
|
||
configured: false,
|
||
logo: '智'
|
||
},
|
||
{
|
||
id: -4,
|
||
providerName: '阿里云百炼',
|
||
providerCode: 'qianwen',
|
||
providerDesc: '阿里云百炼大模型服务',
|
||
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||
status: '0',
|
||
sortOrder: 2,
|
||
source: 'seed',
|
||
configured: false,
|
||
logo: '百'
|
||
},
|
||
{
|
||
id: -1,
|
||
providerName: 'OpenAI兼容接口',
|
||
providerCode: 'custom_api',
|
||
providerDesc: '自定义 OpenAI 兼容接口',
|
||
apiHost: '自定义',
|
||
status: '1',
|
||
sortOrder: 3,
|
||
source: 'seed',
|
||
configured: false,
|
||
logo: 'AI'
|
||
},
|
||
{
|
||
id: -5,
|
||
providerName: 'Ollama(本地)',
|
||
providerCode: 'ollama',
|
||
providerDesc: '本地 Ollama 服务',
|
||
apiHost: 'http://127.0.0.1:11434/v1',
|
||
status: '0',
|
||
sortOrder: 4,
|
||
source: 'seed',
|
||
configured: false,
|
||
logo: 'OL'
|
||
},
|
||
{
|
||
id: -6,
|
||
providerName: '硅基流动',
|
||
providerCode: 'siliconflow',
|
||
providerDesc: '硅基流动 OpenAI 兼容接口',
|
||
apiHost: 'https://api.siliconflow.cn/v1',
|
||
status: '0',
|
||
sortOrder: 5,
|
||
source: 'seed',
|
||
configured: false,
|
||
logo: 'SF'
|
||
}
|
||
];
|
||
|
||
const seedModels: ModelConfig[] = [
|
||
{
|
||
id: -1,
|
||
category: 'chat',
|
||
modelName: 'deepseek-chat',
|
||
providerCode: 'deepseek',
|
||
modelDescribe: '用于通用对话与 RAG 生成',
|
||
modelShow: 'Y',
|
||
apiHost: '',
|
||
enabled: true,
|
||
apiKeyConfigured: false,
|
||
configured: true
|
||
},
|
||
{
|
||
id: -2,
|
||
category: 'chat',
|
||
modelName: 'deepseek-reasoner',
|
||
providerCode: 'deepseek',
|
||
modelDescribe: '用于复杂评分解释',
|
||
modelShow: 'Y',
|
||
apiHost: '',
|
||
enabled: true,
|
||
apiKeyConfigured: false,
|
||
configured: true
|
||
},
|
||
{
|
||
id: -3,
|
||
category: 'vector',
|
||
modelName: 'embedding-3',
|
||
providerCode: 'zhipu',
|
||
modelDescribe: '用于知识片段量化',
|
||
modelDimension: 2048,
|
||
modelShow: 'N',
|
||
apiHost: '',
|
||
enabled: false,
|
||
apiKeyConfigured: false,
|
||
configured: false
|
||
},
|
||
{
|
||
id: -4,
|
||
category: 'rerank',
|
||
modelName: 'rerank',
|
||
providerCode: 'zhipu',
|
||
modelDescribe: '用于召回片段重排',
|
||
modelShow: 'N',
|
||
apiHost: '',
|
||
enabled: false,
|
||
apiKeyConfigured: false,
|
||
configured: false
|
||
},
|
||
{
|
||
id: -5,
|
||
category: 'vector',
|
||
modelName: 'BAAI/bge-m3',
|
||
providerCode: 'siliconflow',
|
||
modelDescribe: '硅基流动 bge-m3,用于知识片段向量化',
|
||
modelDimension: 1024,
|
||
modelShow: 'N',
|
||
apiHost: '',
|
||
enabled: false,
|
||
apiKeyConfigured: false,
|
||
configured: false
|
||
}
|
||
];
|
||
|
||
const providers = ref<ProviderView[]>(seedProviders);
|
||
const modelRows = ref<ModelConfig[]>(seedModels);
|
||
const selectedProviderCode = ref('deepseek');
|
||
const keyword = ref('');
|
||
const loading = ref(false);
|
||
const testing = ref(false);
|
||
const saving = ref(false);
|
||
const promptLoading = ref(false);
|
||
const promptSaving = ref(false);
|
||
const vectorLoading = ref(false);
|
||
const rebuilding = ref(false);
|
||
const showKey = ref(false);
|
||
const providerDialogVisible = ref(false);
|
||
const modelDialogVisible = ref(false);
|
||
const promptDialogVisible = ref(false);
|
||
const lastTestAt = ref('未测试');
|
||
const vectorStatus = ref<VectorIndexStatus>();
|
||
const promptTemplates = ref<PromptTemplateResponse[]>([]);
|
||
|
||
const providerForm = reactive<ProviderFormState>({
|
||
id: 0,
|
||
originalProviderCode: '',
|
||
providerName: '',
|
||
providerCode: '',
|
||
providerDesc: '',
|
||
apiHost: '',
|
||
apiKey: '',
|
||
status: '0',
|
||
sortOrder: 0
|
||
});
|
||
|
||
const modelForm = reactive<ModelFormState>({
|
||
id: 0,
|
||
category: 'chat',
|
||
modelName: '',
|
||
providerCode: '',
|
||
modelDescribe: '',
|
||
modelDimension: undefined,
|
||
modelShow: 'Y',
|
||
apiHost: '',
|
||
apiKey: '',
|
||
enabled: true
|
||
});
|
||
|
||
const promptForm = reactive<PromptTemplateFormState>({
|
||
id: 0,
|
||
code: '',
|
||
name: '',
|
||
category: 'practice',
|
||
outputFormat: '',
|
||
systemPrompt: '',
|
||
template: '',
|
||
variables: '',
|
||
version: 'v1',
|
||
enabled: true,
|
||
description: ''
|
||
});
|
||
|
||
const filteredProviders = computed(() => {
|
||
const word = keyword.value.trim().toLowerCase();
|
||
if (!word) return providers.value;
|
||
return providers.value.filter((item) => `${item.providerName} ${item.providerCode}`.toLowerCase().includes(word));
|
||
});
|
||
|
||
const activeProvider = computed<ProviderView>(
|
||
() => providers.value.find((item) => item.providerCode === selectedProviderCode.value) ?? providers.value[0] ?? seedProviders[0]
|
||
);
|
||
const activeChatModel = computed(() =>
|
||
modelRows.value.find(
|
||
(item) => item.id > 0 && item.providerCode === selectedProviderCode.value && item.category === 'chat' && item.enabled && item.configured
|
||
)
|
||
);
|
||
const keyText = computed(() => {
|
||
if (!activeProvider.value.configured) return '未配置';
|
||
return showKey.value ? '已配置,接口不回传密钥' : 'sk-************';
|
||
});
|
||
const providerEnabled = computed({
|
||
get: () => providerForm.status === '0',
|
||
set: (value: boolean) => {
|
||
providerForm.status = value ? '0' : '1';
|
||
}
|
||
});
|
||
const vectorStatusLabel = computed(() => (vectorStatus.value?.matched ? '一致' : vectorStatus.value?.message || '需重建'));
|
||
|
||
const providerTag = (provider: ProviderView) => {
|
||
if (provider.providerCode.includes('ollama')) return { label: '本地', className: 'neutral' };
|
||
if (provider.status !== '0') return { label: '未启用', className: 'warning' };
|
||
if (!provider.configured) return { label: '未配置', className: 'warning' };
|
||
return { label: '已启用', className: 'success' };
|
||
};
|
||
|
||
const normalizeProviders = (items: ModelProvider[]): ProviderView[] => {
|
||
if (!items.length) return seedProviders;
|
||
const seedMap = new Map(seedProviders.map((item) => [item.providerCode, item]));
|
||
const rows = items.map((item) => {
|
||
const seed = seedMap.get(item.providerCode);
|
||
return {
|
||
...item,
|
||
logo: seed?.logo ?? item.providerName.slice(0, 2).toUpperCase()
|
||
};
|
||
});
|
||
const existingCodes = new Set(rows.map((item) => item.providerCode));
|
||
return [...rows, ...seedProviders.filter((item) => !existingCodes.has(item.providerCode))];
|
||
};
|
||
|
||
const modelKey = (item: ModelConfig) => `${item.category}:${item.providerCode}:${item.modelName}`;
|
||
|
||
const normalizeModels = (items: ModelConfig[]): ModelConfig[] => {
|
||
if (!items.length) return seedModels;
|
||
const existingKeys = new Set(items.map(modelKey));
|
||
return [...items, ...seedModels.filter((item) => !existingKeys.has(modelKey(item)))];
|
||
};
|
||
|
||
const loadData = async (preferredProviderCode = selectedProviderCode.value) => {
|
||
loading.value = true;
|
||
try {
|
||
const [providerResult, configResult] = await Promise.all([getModelProviders(), getModelConfigs()]);
|
||
providers.value = normalizeProviders(providerResult.data ?? []);
|
||
modelRows.value = normalizeModels(configResult.data ?? []);
|
||
selectedProviderCode.value =
|
||
providers.value.find((item) => item.providerCode === preferredProviderCode)?.providerCode ?? providers.value[0]?.providerCode ?? 'deepseek';
|
||
} catch {
|
||
providers.value = seedProviders;
|
||
modelRows.value = seedModels;
|
||
selectedProviderCode.value = preferredProviderCode || 'deepseek';
|
||
ElMessage.warning('模型配置 API 不可用,已展示内置默认清单');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const loadVectorStatus = async () => {
|
||
vectorLoading.value = true;
|
||
try {
|
||
const result = await getVectorIndexStatus();
|
||
vectorStatus.value = result.data;
|
||
} catch {
|
||
vectorStatus.value = undefined;
|
||
} finally {
|
||
vectorLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const loadPromptTemplates = async () => {
|
||
promptLoading.value = true;
|
||
try {
|
||
const result = await listPromptTemplates();
|
||
promptTemplates.value = result.data ?? [];
|
||
} catch {
|
||
promptTemplates.value = [];
|
||
} finally {
|
||
promptLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const runVectorRebuild = async () => {
|
||
try {
|
||
await ElMessageBox.confirm('将清空旧片段向量并重建 Qdrant 索引,继续吗?', '重建向量索引', {
|
||
confirmButtonText: '重建',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
rebuilding.value = true;
|
||
try {
|
||
const result = await rebuildVectorIndex();
|
||
await loadVectorStatus();
|
||
ElMessage.success(`已重建 ${result.data.documents} 份资料 / ${result.data.fragments} 个片段`);
|
||
} finally {
|
||
rebuilding.value = false;
|
||
}
|
||
};
|
||
|
||
const runConnectionTest = async () => {
|
||
if (!activeChatModel.value) {
|
||
ElMessage.warning('请先为当前供应商配置并启用 chat 模型');
|
||
return;
|
||
}
|
||
testing.value = true;
|
||
try {
|
||
const result = await testModelChat({
|
||
model: activeChatModel.value?.modelName,
|
||
prompt: '请用一句话说明模型配置已连接。'
|
||
});
|
||
lastTestAt.value = new Date().toLocaleString('zh-CN', { hour12: false });
|
||
if (result.data?.configured) {
|
||
ElMessage.success(`连接成功:${result.data.modelName}`);
|
||
} else {
|
||
ElMessage.warning('当前模型未完成真实调用配置,请检查供应商 URL、Key 和模型名');
|
||
}
|
||
} catch {
|
||
ElMessage.warning('模型测试接口不可用,请检查后端服务、登录状态或当前供应商配置');
|
||
} finally {
|
||
testing.value = false;
|
||
}
|
||
};
|
||
|
||
const openProviderDialog = (provider?: ProviderView) => {
|
||
const source = provider ?? undefined;
|
||
Object.assign(providerForm, {
|
||
id: source && source.id > 0 ? source.id : 0,
|
||
originalProviderCode: source && source.id > 0 ? source.providerCode : '',
|
||
providerName: source?.providerName ?? '',
|
||
providerCode: source?.providerCode ?? '',
|
||
providerDesc: source?.providerDesc ?? '',
|
||
apiHost: source?.apiHost === '自定义' ? '' : (source?.apiHost ?? ''),
|
||
apiKey: '',
|
||
status: source?.status ?? '0',
|
||
sortOrder: source?.sortOrder ?? providers.value.length
|
||
});
|
||
providerDialogVisible.value = true;
|
||
};
|
||
|
||
const providerPayload = (): ModelProviderPayload | undefined => {
|
||
const providerName = providerForm.providerName.trim();
|
||
const providerCode = providerForm.providerCode.trim();
|
||
if (!providerName || !providerCode) {
|
||
ElMessage.warning('请填写供应商名称和编码');
|
||
return undefined;
|
||
}
|
||
return {
|
||
providerName,
|
||
providerCode,
|
||
providerDesc: providerForm.providerDesc.trim(),
|
||
apiHost: providerForm.apiHost.trim(),
|
||
apiKey: providerForm.apiKey.trim(),
|
||
status: providerForm.status,
|
||
sortOrder: providerForm.sortOrder
|
||
};
|
||
};
|
||
|
||
const submitProvider = async () => {
|
||
const payload = providerPayload();
|
||
if (!payload) return;
|
||
|
||
saving.value = true;
|
||
try {
|
||
if (providerForm.id > 0) {
|
||
await updateModelProvider(providerForm.originalProviderCode || providerForm.providerCode, payload);
|
||
} else {
|
||
await createModelProvider(payload);
|
||
}
|
||
providerDialogVisible.value = false;
|
||
await loadData(payload.providerCode);
|
||
ElMessage.success('供应商配置已保存');
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
};
|
||
|
||
const toggleProviderStatus = async (enabled: boolean) => {
|
||
const provider = activeProvider.value;
|
||
if (provider.id < 0) {
|
||
ElMessage.warning('内置供应商请先保存后再启停');
|
||
openProviderDialog(provider);
|
||
return;
|
||
}
|
||
|
||
const status = enabled ? '0' : '1';
|
||
try {
|
||
await updateModelProviderStatus(provider.providerCode, status);
|
||
await loadData(provider.providerCode);
|
||
ElMessage.success(enabled ? '供应商已启用' : '供应商已停用');
|
||
} catch {
|
||
await loadData(provider.providerCode);
|
||
}
|
||
};
|
||
|
||
const openModelDialog = (model?: ModelConfig) => {
|
||
Object.assign(modelForm, {
|
||
id: model && model.id > 0 ? model.id : 0,
|
||
category: model?.category ?? 'chat',
|
||
modelName: model?.modelName ?? '',
|
||
providerCode: model?.providerCode ?? selectedProviderCode.value,
|
||
modelDescribe: model?.modelDescribe ?? '',
|
||
modelDimension: model?.modelDimension,
|
||
modelShow: model?.modelShow ?? 'Y',
|
||
apiHost: model?.apiHost ?? '',
|
||
apiKey: '',
|
||
enabled: model?.enabled ?? true
|
||
});
|
||
modelDialogVisible.value = true;
|
||
};
|
||
|
||
const modelPayload = (): ModelConfigPayload | undefined => {
|
||
const modelName = modelForm.modelName.trim();
|
||
const providerCode = modelForm.providerCode.trim();
|
||
if (!modelName || !providerCode) {
|
||
ElMessage.warning('请填写模型名称和供应商');
|
||
return undefined;
|
||
}
|
||
return {
|
||
category: modelForm.category,
|
||
modelName,
|
||
providerCode,
|
||
modelDescribe: modelForm.modelDescribe.trim(),
|
||
modelDimension: modelForm.modelDimension && modelForm.modelDimension > 0 ? modelForm.modelDimension : undefined,
|
||
modelShow: modelForm.modelShow,
|
||
apiHost: modelForm.apiHost.trim(),
|
||
apiKey: modelForm.apiKey.trim(),
|
||
enabled: modelForm.enabled
|
||
};
|
||
};
|
||
|
||
const submitModel = async () => {
|
||
const payload = modelPayload();
|
||
if (!payload) return;
|
||
|
||
saving.value = true;
|
||
try {
|
||
if (modelForm.id > 0) {
|
||
await updateModelConfig(modelForm.id, payload);
|
||
} else {
|
||
await createModelConfig(payload);
|
||
}
|
||
modelDialogVisible.value = false;
|
||
await loadData(payload.providerCode);
|
||
await loadVectorStatus();
|
||
ElMessage.success('模型配置已保存');
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
};
|
||
|
||
const toggleModelEnabled = async (row: ModelConfig, enabled: boolean) => {
|
||
if (row.id < 0) {
|
||
row.enabled = !enabled;
|
||
ElMessage.warning('内置模型请先保存后再启停');
|
||
openModelDialog(row);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await updateModelConfigEnabled(row.id, enabled);
|
||
row.enabled = enabled;
|
||
await loadVectorStatus();
|
||
ElMessage.success(enabled ? '模型已启用' : '模型已停用');
|
||
} catch {
|
||
row.enabled = !enabled;
|
||
}
|
||
};
|
||
|
||
const openPromptDialog = (template?: PromptTemplateResponse) => {
|
||
Object.assign(promptForm, {
|
||
id: template?.id ?? 0,
|
||
code: template?.code ?? '',
|
||
name: template?.name ?? '',
|
||
category: template?.category ?? 'practice',
|
||
outputFormat: template?.outputFormat ?? '',
|
||
systemPrompt: template?.systemPrompt ?? '',
|
||
template: template?.template ?? '',
|
||
variables: template?.variables ?? '',
|
||
version: template?.version ?? 'v1',
|
||
enabled: template?.enabled ?? true,
|
||
description: template?.description ?? ''
|
||
});
|
||
promptDialogVisible.value = true;
|
||
};
|
||
|
||
const promptPayload = (): PromptTemplateRequest | undefined => {
|
||
const code = promptForm.code.trim();
|
||
const name = promptForm.name.trim();
|
||
const template = promptForm.template.trim();
|
||
if (!code || !name || !template) {
|
||
ElMessage.warning('请填写模板代码、名称和提示词模板');
|
||
return undefined;
|
||
}
|
||
return {
|
||
code,
|
||
name,
|
||
category: promptForm.category.trim() || 'practice',
|
||
outputFormat: promptForm.outputFormat.trim() || code,
|
||
systemPrompt: promptForm.systemPrompt.trim(),
|
||
template,
|
||
variables: promptForm.variables.trim(),
|
||
version: promptForm.version.trim() || 'v1',
|
||
enabled: promptForm.enabled,
|
||
description: promptForm.description.trim()
|
||
};
|
||
};
|
||
|
||
const submitPromptTemplate = async () => {
|
||
const payload = promptPayload();
|
||
if (!payload) return;
|
||
|
||
promptSaving.value = true;
|
||
try {
|
||
if (promptForm.id > 0) {
|
||
await updatePromptTemplate(promptForm.id, payload);
|
||
} else {
|
||
await createPromptTemplate(payload);
|
||
}
|
||
promptDialogVisible.value = false;
|
||
await loadPromptTemplates();
|
||
ElMessage.success('提示词模板已保存');
|
||
} finally {
|
||
promptSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const togglePromptEnabled = async (row: PromptTemplateResponse, enabled: boolean) => {
|
||
try {
|
||
await updatePromptTemplate(row.id, { ...row, enabled });
|
||
row.enabled = enabled;
|
||
ElMessage.success(enabled ? '模板已启用' : '模板已停用');
|
||
} catch {
|
||
row.enabled = !enabled;
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
loadData();
|
||
loadVectorStatus();
|
||
loadPromptTemplates();
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.model-config-page {
|
||
min-height: calc(100vh - 84px);
|
||
padding: 28px 32px 34px;
|
||
background: #f4f7fb;
|
||
color: #172033;
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
margin-bottom: 24px;
|
||
|
||
h1 {
|
||
margin: 0 0 12px;
|
||
font-size: 30px;
|
||
line-height: 1.2;
|
||
font-weight: 800;
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
color: #5f6f86;
|
||
font-size: 15px;
|
||
}
|
||
}
|
||
|
||
.head-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding-top: 2px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.model-layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(280px, 330px) minmax(0, 1fr);
|
||
gap: 20px;
|
||
}
|
||
|
||
.panel {
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
box-shadow: 0 10px 24px rgb(15 23 42 / 5%);
|
||
}
|
||
|
||
.panel-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 22px 24px 14px;
|
||
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
line-height: 1.2;
|
||
font-weight: 800;
|
||
}
|
||
}
|
||
|
||
.provider-panel {
|
||
overflow: hidden;
|
||
|
||
footer {
|
||
padding: 18px 24px 22px;
|
||
color: #64748b;
|
||
border-top: 1px solid #edf1f6;
|
||
}
|
||
}
|
||
|
||
.provider-search {
|
||
width: calc(100% - 40px);
|
||
margin: 4px 20px 18px;
|
||
}
|
||
|
||
.provider-list {
|
||
min-height: 420px;
|
||
}
|
||
|
||
.provider-item {
|
||
display: grid;
|
||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
width: 100%;
|
||
min-height: 82px;
|
||
padding: 14px 22px;
|
||
border: 0;
|
||
border-top: 1px solid #edf1f6;
|
||
border-left: 4px solid transparent;
|
||
background: #fff;
|
||
color: #172033;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
|
||
&.active {
|
||
border-left-color: #e60012;
|
||
background: linear-gradient(90deg, #fff1f2 0%, #fff 100%);
|
||
}
|
||
}
|
||
|
||
.provider-logo {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 8px;
|
||
background: #eef4ff;
|
||
color: #2264d1;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.logo-zhipu {
|
||
background: #f3e8ff;
|
||
color: #7c3aed;
|
||
}
|
||
|
||
.logo-qianwen {
|
||
background: #e0f2fe;
|
||
color: #0891b2;
|
||
}
|
||
|
||
.logo-custom_api {
|
||
background: #f1f5f9;
|
||
color: #111827;
|
||
}
|
||
|
||
.logo-ollama {
|
||
background: #f8fafc;
|
||
color: #334155;
|
||
}
|
||
|
||
.logo-siliconflow {
|
||
background: #f3e8ff;
|
||
color: #6d28d9;
|
||
}
|
||
|
||
.provider-text {
|
||
min-width: 0;
|
||
|
||
strong,
|
||
small {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
strong {
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
small {
|
||
margin-top: 5px;
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.status-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 52px;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
|
||
&.success {
|
||
color: #17834d;
|
||
background: #dcfce7;
|
||
}
|
||
|
||
&.warning {
|
||
color: #c2410c;
|
||
background: #ffedd5;
|
||
}
|
||
|
||
&.neutral {
|
||
color: #475569;
|
||
background: #eef2f7;
|
||
}
|
||
}
|
||
|
||
.detail-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.detail-panel,
|
||
.vector-panel,
|
||
.model-panel,
|
||
.prompt-panel {
|
||
padding-bottom: 18px;
|
||
}
|
||
|
||
.with-actions {
|
||
padding-bottom: 18px;
|
||
border-bottom: 1px solid #edf1f6;
|
||
|
||
> div {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
}
|
||
|
||
.detail-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.72fr);
|
||
gap: 34px;
|
||
padding: 22px 24px 6px;
|
||
}
|
||
|
||
.vector-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(220px, 1fr));
|
||
gap: 18px 24px;
|
||
padding: 22px 24px 6px;
|
||
}
|
||
|
||
.field-list {
|
||
display: grid;
|
||
gap: 20px;
|
||
|
||
&.side {
|
||
padding-left: 32px;
|
||
border-left: 1px solid #edf1f6;
|
||
}
|
||
}
|
||
|
||
.field-row {
|
||
display: grid;
|
||
grid-template-columns: 116px minmax(0, 1fr);
|
||
align-items: center;
|
||
gap: 16px;
|
||
|
||
span {
|
||
color: #64748b;
|
||
font-size: 15px;
|
||
}
|
||
|
||
strong {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
color: #25324a;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
word-break: break-all;
|
||
}
|
||
}
|
||
|
||
.model-table {
|
||
width: calc(100% - 40px);
|
||
margin: 4px 20px 0;
|
||
border: 1px solid #edf1f6;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.tip-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-height: 54px;
|
||
padding: 0 18px;
|
||
border: 1px solid #fecaca;
|
||
border-radius: 8px;
|
||
background: #fff7f7;
|
||
color: #8a2f33;
|
||
}
|
||
|
||
:deep(.el-button--danger) {
|
||
--el-button-bg-color: #e60012;
|
||
--el-button-border-color: #e60012;
|
||
--el-button-hover-bg-color: #f0202f;
|
||
--el-button-hover-border-color: #f0202f;
|
||
}
|
||
|
||
:deep(.el-table th.el-table__cell) {
|
||
background: #f8fafc;
|
||
color: #334155;
|
||
font-weight: 800;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.page-head,
|
||
.head-actions {
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.model-layout,
|
||
.detail-grid,
|
||
.vector-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.field-list.side {
|
||
padding-left: 0;
|
||
border-left: 0;
|
||
}
|
||
}
|
||
</style>
|