本地 RAG 知识库
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.
 
 
 
 
 

558 lines
22 KiB

/**
* ⚙️ AI 模型配置管理组件
* 展示模型配置列表、新增/编辑/激活/删除配置
*/
import { ref, onMounted, watch } from 'vue'
import * as api from '../js/api.js'
import { toast, formatDate } from '../js/utils.js'
// 应用类型选项
const APP_TYPE_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'CHAT', label: '智能客服对话' },
{ value: 'EMBEDDING', label: '文本向量化' },
{ value: 'RAG_REWRITE', label: 'RAG查询重写' }
]
// 提供商选项
const PROVIDER_OPTIONS = [
{ value: 'dashscope', label: '通义千问 (DashScope)' },
{ value: 'deepseek', label: 'DeepSeek (深度求索)' },
{ value: 'volcengine', label: '豆包 (字节跳动)' },
{ value: 'moonshot', label: 'Kimi (月之暗面)' },
{ value: 'zhipu', label: '智谱 AI (GLM)' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'other', label: '其他' }
]
// 提供商默认配置(切换时自动填充)
// chatModels / embeddingModels 按 app_type 分别展示,models 作为向后兼容兜底
const PROVIDER_DEFAULTS = {
dashscope: {
baseUrl: '',
chatModels: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-long'],
embeddingModels: ['text-embedding-v2'],
defaultChatModel: 'qwen-turbo',
defaultEmbeddingModel: 'text-embedding-v2',
tip: '通义千问使用 DashScope 自动配置,Base URL 留空即可'
},
deepseek: {
baseUrl: 'https://api.deepseek.com',
chatModels: ['deepseek-chat', 'deepseek-reasoner'],
embeddingModels: [],
defaultChatModel: 'deepseek-chat',
defaultEmbeddingModel: '',
tip: ''
},
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'],
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'],
embeddingModels: [],
defaultChatModel: 'moonshot-v1-8k',
defaultEmbeddingModel: '',
tip: ''
},
zhipu: {
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
chatModels: ['glm-4-plus', 'glm-4-flash', 'glm-4-long', 'glm-4'],
embeddingModels: [],
defaultChatModel: 'glm-4-flash',
defaultEmbeddingModel: '',
tip: ''
},
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'],
defaultChatModel: 'gpt-4o-mini',
defaultEmbeddingModel: 'text-embedding-3-small',
tip: ''
},
other: {
baseUrl: '',
chatModels: [],
embeddingModels: [],
defaultChatModel: '',
defaultEmbeddingModel: '',
tip: '使用 OpenAI 兼容 API 的其他提供商,请填写 Base URL 和模型名称'
}
}
export default {
template: `
<div class="card">
<h2>⚙️ AI 大模型配置管理</h2>
<!-- 筛选栏 -->
<div class="input-row">
<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>
<button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button>
</div>
<!-- 配置列表表格 -->
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>配置名称</th>
<th>应用类型</th>
<th>模型名称</th>
<th>提供商</th>
<th>温度</th>
<th>API Key</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="configs.length === 0">
<td colspan="8" style="text-align:center;color:var(--sub);">暂无配置记录</td>
</tr>
<tr v-for="c in configs" :key="c.id">
<td>
<strong>{{ c.name || '-' }}</strong>
<div v-if="c.description" style="font-size:11px;color:var(--sub);margin-top:2px;">{{ c.description }}</div>
</td>
<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>{{ 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>
<td>
<span v-if="c.is_active" style="color:#16a34a;font-weight:600;">
🟢 活跃
</span>
<span v-else style="color:var(--sub);">
⚫ 未激活
</span>
</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>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination" v-if="totalPages > 1">
<button :disabled="currentPage <= 1" @click="load(currentPage - 1)">上一页</button>
<template v-for="i in totalPages" :key="i">
<button v-if="i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)"
:class="{ active: i === currentPage }" @click="load(i)">{{ i }}</button>
<span v-else-if="i === currentPage - 3 || i === currentPage + 3" style="padding:6px;">...</span>
</template>
<button :disabled="currentPage >= totalPages" @click="load(currentPage + 1)">下一页</button>
</div>
</div>
<!-- 编辑/新建弹窗 -->
<div class="modal-overlay" :class="{ active: editModal.visible }">
<div class="modal-box" style="max-width:860px;">
<button class="modal-close" @click="closeEditModal">×</button>
<h2>{{ editModal.mode === 'add' ? '➕ 新建模型配置' : '✏️ 编辑模型配置' }}</h2>
<div class="model-form">
<!-- 配置名称(跨列) -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">配置名称 <span style="color:#dc2626;">*</span></label>
<input type="text" class="input" v-model="editModal.form.name" placeholder="如:生产环境-DeepSeek对话">
</div>
<!-- 应用类型 + 提供商(双列) -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">应用类型 <span style="color:#dc2626;">*</span></label>
<select class="input" v-model="editModal.form.app_type" :disabled="editModal.mode === 'edit'">
<option v-for="opt in appTypeOptions.slice(1)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">提供商 <span style="color:#dc2626;">*</span></label>
<select class="input" v-model="editModal.form.provider" @change="onProviderChange">
<option v-for="opt in providerOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<!-- 提供商提示(跨列) -->
<div v-if="providerTip" class="model-form-full" style="padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;font-size:12px;color:#1e40af;">
💡 {{ providerTip }}
</div>
<!-- API Key(跨列) -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API Key <span style="color:#dc2626;">*</span></label>
<div style="display:flex;gap:8px;">
<input :type="showApiKey ? 'text' : 'password'" class="input" v-model="editModal.form.api_key"
:placeholder="editModal.mode === 'edit' ? '留空则不修改' : '请输入 API Key'" style="flex:1;">
<button class="btn btn-sm btn-outline" @click="showApiKey = !showApiKey" style="white-space:nowrap;">
{{ showApiKey ? '🙈 隐藏' : '👁️ 显示' }}
</button>
</div>
</div>
<!-- 模型名称(跨列) -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">模型名称 <span style="color:#dc2626;">*</span></label>
<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>
<option value="__custom__">自定义输入...</option>
</select>
<input v-if="editModal.form.model_name === '__custom__'" type="text" class="input"
v-model="customModelName" placeholder="输入自定义模型名称" style="margin-top:6px;">
</div>
<!-- 温度 + 最大 Token(双列) -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">温度 (Temperature)</label>
<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">
</div>
<!-- Base URL(跨列) -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API 基础地址</label>
<input type="text" class="input" v-model="editModal.form.base_url" :placeholder="providerBaseUrlPlaceholder">
</div>
<!-- 向量维度(仅 EMBEDDING 类型,跨列) -->
<div v-if="editModal.form.app_type === 'EMBEDDING'" class="model-form-full" style="padding:10px 14px;background:#fefce8;border:1px solid #fde68a;border-radius:8px;">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:6px;">📐 向量维度 <span style="color:#dc2626;">*</span></label>
<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>
<!-- 优先级 + 是否激活(双列) -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">优先级</label>
<input type="number" class="input" v-model.number="editModal.form.priority" min="0" placeholder="0(数值越大越优先)">
</div>
<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">
设为活跃配置(同类型只能有一个活跃)
</label>
</div>
<!-- 描述(跨列) -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">描述说明</label>
<textarea class="input" v-model="editModal.form.description" rows="2" placeholder="可选,填写配置用途说明"></textarea>
</div>
</div>
<!-- 按钮 -->
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;">
<button class="btn btn-outline" @click="closeEditModal">取消</button>
<button class="btn btn-primary" @click="saveConfig">💾 保存</button>
</div>
</div>
</div>
`,
setup() {
const configs = ref([])
const currentPage = ref(1)
const totalPages = ref(1)
const total = ref(0)
const filterAppType = ref('')
const showApiKey = ref(false)
const customModelName = ref('')
const providerTip = ref('')
// 计算当前提供商的模型列表和默认值
const currentProviderModels = ref([])
const currentProviderDefaultModel = ref('')
const providerBaseUrlPlaceholder = ref('')
// 编辑弹窗状态
const editModal = ref({
visible: false,
mode: 'add',
editId: null,
form: createEmptyForm()
})
function createEmptyForm() {
return {
name: '',
app_type: 'CHAT',
provider: 'dashscope',
api_key: '',
model_name: '',
temperature: 0.7,
max_tokens: 2000,
base_url: '',
embeddingDimensions: 1024,
priority: 0,
is_active: false,
description: ''
}
}
// 提供商切换时自动填充(按 app_type 区分对话/向量模型)
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
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
}
currentProviderModels.value = models
currentProviderDefaultModel.value = defaultModel
providerTip.value = defaults.tip || ''
providerBaseUrlPlaceholder.value = defaults.baseUrl || '私有化部署时可填写自定义地址'
customModelName.value = ''
}
// 监听 app_type 变化,刷新模型列表
watch(() => editModal.value.form.app_type, () => {
if (editModal.value.visible) {
onProviderChange()
}
})
// ==================== 数据加载 ====================
async function load(p = 1) {
currentPage.value = p
try {
const json = await api.listModelConfigs(p, 10, filterAppType.value || undefined)
if (json.success) {
configs.value = json.data || []
total.value = json.total || 0
totalPages.value = json.pages || 1
} else {
toast(json.message || '查询失败', 'error')
}
} catch (e) {
toast('加载配置列表失败:' + e.message, 'error')
}
}
// ==================== 弹窗操作 ====================
function openAddModal() {
editModal.value = {
visible: true,
mode: 'add',
editId: null,
form: createEmptyForm()
}
showApiKey.value = false
onProviderChange()
}
function openEditModal(config) {
// 从 extraConfig 中读取 embeddingDimensions
let extraConfig = {}
if (config.extraConfig) {
try {
extraConfig = typeof config.extraConfig === 'string' ? JSON.parse(config.extraConfig) : config.extraConfig
} catch (e) {}
}
editModal.value = {
visible: true,
mode: 'edit',
editId: config.id,
form: {
name: config.name || '',
app_type: config.app_type || 'CHAT',
provider: config.provider || 'dashscope',
api_key: '',
model_name: config.model_name || '',
temperature: config.temperature,
max_tokens: config.max_tokens,
base_url: config.base_url || '',
embeddingDimensions: extraConfig.dimensions || 1024,
priority: config.priority || 0,
is_active: config.is_active || false,
description: config.description || ''
}
}
showApiKey.value = false
onProviderChange()
}
function closeEditModal() {
editModal.value.visible = false
}
// ==================== 保存配置 ====================
/**
* 将前端表单字段名(snake_case)转换为后端 Java 实体字段名(camelCase)
* 因为 Jackson 默认使用 camelCase 反序列化,前端发送 snake_case 会导致字段无法映射
*/
function toCamelCase(form) {
const data = {
name: form.name,
appType: form.app_type,
provider: form.provider,
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
}
// EMBEDDING 类型:将向量维度写入 extraConfig
if (form.app_type === 'EMBEDDING') {
data.extraConfig = {
dimensions: form.embeddingDimensions || 1024
}
}
return data
}
async function saveConfig() {
const form = editModal.value.form
// 如果选了"自定义输入",取自定义名称
if (form.model_name === '__custom__') {
if (!customModelName.value.trim()) {
toast('请输入自定义模型名称', 'error')
return
}
form.model_name = customModelName.value.trim()
}
if (!form.name || !form.name.trim()) {
toast('请填写配置名称', 'error')
return
}
if (!form.app_type) {
toast('请选择应用类型', 'error')
return
}
if (!form.model_name || !form.model_name.trim()) {
toast('请填写模型名称', 'error')
return
}
if (editModal.value.mode === 'add' && (!form.api_key || !form.api_key.trim())) {
toast('请填写 API Key', 'error')
return
}
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
}
json = await api.updateModelConfig(editModal.value.editId, camelData)
}
if (json.success) {
toast(editModal.value.mode === 'add' ? '配置创建成功,已切换生效' : '配置更新成功,已切换生效', 'success')
closeEditModal()
load(currentPage.value)
} else {
toast(json.message || '操作失败', 'error')
}
} catch (e) {
toast('保存失败:' + e.message, 'error')
}
}
// ==================== 激活配置 ====================
async function activate(id) {
if (!confirm('确定激活此配置?同类型的其他配置将被自动停用,新配置将立即生效。')) return
try {
const json = await api.activateModelConfig(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) {
if (!confirm('确定删除配置「' + (name || id) + '」?')) return
try {
const json = await api.deleteModelConfig(id)
if (json.success) {
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查询重写' }
return map[appType] || appType
}
function getAppTypeBadgeClass(appType) {
const map = { CHAT: 'badge-get', EMBEDDING: '', RAG_REWRITE: '' }
return map[appType] || ''
}
function getProviderLabel(provider) {
const found = PROVIDER_OPTIONS.find(o => o.value === provider)
return found ? found.label : provider
}
// 初始加载
onMounted(() => { load() })
return {
configs, currentPage, totalPages, total, filterAppType, showApiKey, editModal,
appTypeOptions: APP_TYPE_OPTIONS, providerOptions: PROVIDER_OPTIONS,
providerTip, currentProviderModels, currentProviderDefaultModel, providerBaseUrlPlaceholder, customModelName,
load, openAddModal, openEditModal, closeEditModal, saveConfig, activate, remove,
onProviderChange, getAppTypeLabel, getAppTypeBadgeClass, getProviderLabel, formatDate
}
}
}