更新
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>AI 模型配置</h2>
|
||||
<p>对接 OpenAI 格式 API 接口</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button v-if="auth.hasButton('btn:model:create')" class="btn btn-primary" @click="openCreate">添加模型</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>接口类型</th>
|
||||
<th>Model ID</th>
|
||||
<th>API 地址</th>
|
||||
<th>Max Tokens</th>
|
||||
<th>默认</th>
|
||||
<th>上下文</th>
|
||||
<th>图片</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in models" :key="m.id">
|
||||
<td>{{ m.name }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="m.provider === 'dify' ? 'badge-info' : 'badge-success'">
|
||||
{{ m.provider === 'dify' ? 'Dify' : 'OpenAI 兼容' }}
|
||||
</span>
|
||||
</td>
|
||||
<td><code>{{ m.model_id || '-' }}</code></td>
|
||||
<td class="url-cell">{{ m.api_base_url }}</td>
|
||||
<td>{{ m.provider === 'dify' ? '-' : m.max_tokens }}</td>
|
||||
<td>{{ m.is_default ? '✓' : '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="m.support_context ? 'badge-success' : 'badge-info'">
|
||||
{{ m.support_context ? '支持' : '不支持' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="m.support_image ? 'badge-success' : 'badge-info'">
|
||||
{{ m.support_image ? '支持' : '不支持' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="m.enabled ? 'badge-success' : 'badge-danger'">
|
||||
{{ m.enabled ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button v-if="auth.hasButton('btn:model:edit')" class="btn btn-ghost" @click="openEdit(m)">编辑</button>
|
||||
<button v-if="auth.hasButton('btn:model:test')" class="btn btn-ghost" @click="quickTest(m.id)" :disabled="testingId === m.id">
|
||||
{{ testingId === m.id ? '测试中...' : '测试' }}
|
||||
</button>
|
||||
<button v-if="auth.hasButton('btn:model:delete')" class="btn btn-danger" @click="handleDelete(m.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingId ? '编辑模型' : '添加模型' }}</h3>
|
||||
<button @click="showModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>显示名称</label>
|
||||
<input v-model="form.name" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>接口类型</label>
|
||||
<select v-model="form.provider" class="form-input">
|
||||
<option value="openai">OpenAI 兼容(GPT / DeepSeek / vLLM / SGLang 等)</option>
|
||||
<option value="dify">Dify 应用(chat-messages 接口)</option>
|
||||
</select>
|
||||
<p class="field-hint" v-if="form.provider === 'dify'">
|
||||
Dify 使用自己的一套接口协议(/chat-messages),跟 OpenAI 的 /chat/completions 不同,不要混用,否则会报 404
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group" v-if="form.provider !== 'dify'">
|
||||
<label>Model ID</label>
|
||||
<input v-model="form.model_id" class="form-input" placeholder="gpt-4o-mini" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Base URL</label>
|
||||
<input
|
||||
v-model="form.api_base_url"
|
||||
class="form-input"
|
||||
:placeholder="form.provider === 'dify' ? 'https://api.dify.ai/v1(或自部署地址)' : 'https://api.openai.com/v1'"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<p v-if="editingId && hasSavedKey" class="saved-key-hint">
|
||||
已保存 Key:<code>{{ savedKeyHint }}</code>(输入新值可覆盖,留空则不修改)
|
||||
</p>
|
||||
<p v-else-if="editingId && !hasSavedKey" class="field-hint field-hint-warn">
|
||||
当前未配置 Key,请填写
|
||||
</p>
|
||||
<input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
class="form-input"
|
||||
:placeholder="editingId ? '留空则使用已保存的 Key' : (form.provider === 'dify' ? 'Dify 应用“访问 API”页面获取的密钥' : '可选,部分本地模型可不填')"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group" v-if="form.provider !== 'dify'">
|
||||
<label>Max Tokens</label>
|
||||
<input v-model.number="form.max_tokens" type="number" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group" v-if="form.provider !== 'dify'">
|
||||
<label>Temperature</label>
|
||||
<input v-model.number="form.temperature" type="number" step="0.1" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" v-model="form.is_default" /> 设为默认模型</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" v-model="form.enabled" /> 启用</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" v-model="form.support_context" /> 支持上下文(多轮对话)</label>
|
||||
<p class="field-hint" v-if="form.provider === 'dify'">关闭后每次提问都会让 Dify 开启一个新会话,不延续之前的上下文</p>
|
||||
<p class="field-hint" v-else>关闭后每次提问只发送当前这一条消息,不携带历史聊天记录</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" v-model="form.support_image" /> 支持图片/多模态输入</label>
|
||||
<p class="field-hint">关闭后用户仍可上传图片给自己看,但不会把图片发给该模型识别(避免"not a multimodal model"报错),关闭后聊天界面也会自动隐藏图片上传按钮</p>
|
||||
</div>
|
||||
<template v-if="form.provider !== 'dify'">
|
||||
<div class="form-group">
|
||||
<label>Frequency Penalty(频率惩罚)</label>
|
||||
<input v-model.number="form.frequency_penalty" type="number" step="0.1" min="0" max="2" class="form-input" />
|
||||
<p class="field-hint">部分自部署/OCR 类模型容易陷入重复输出循环(同一句话刷屏),调高此值(如 1.0~1.5)可以有效抑制,默认 0 表示不干预</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Presence Penalty(存在惩罚)</label>
|
||||
<input v-model.number="form.presence_penalty" type="number" step="0.1" min="0" max="2" class="form-input" />
|
||||
<p class="field-hint">抑制模型反复围绕同一话题/短语,与频率惩罚搭配使用效果更好,默认 0 表示不干预</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="testResult" class="test-result" :class="testResult.ok ? 'success' : 'error'">
|
||||
<strong>{{ testResult.ok ? '✓ 测试成功' : '✗ 测试失败' }}</strong>
|
||||
<p v-if="testResult.ok">
|
||||
延迟 {{ testResult.latency_ms }}ms
|
||||
<span v-if="testResult.tokens"> · Token {{ testResult.tokens }}</span>
|
||||
</p>
|
||||
<p v-if="testResult.reply" class="reply-preview">模型回复:{{ testResult.reply }}</p>
|
||||
<p v-if="!testResult.ok">{{ testResult.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" @click="showModal = false">取消</button>
|
||||
<button v-if="auth.hasButton('btn:model:test')" class="btn btn-ghost" @click="testModel" :disabled="testing">
|
||||
{{ testing ? '测试中...' : '测试连接' }}
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="saveModel">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const models = ref([])
|
||||
const showModal = ref(false)
|
||||
const editingId = ref(null)
|
||||
const testing = ref(false)
|
||||
const testingId = ref(null)
|
||||
const testResult = ref(null)
|
||||
const hasSavedKey = ref(false)
|
||||
const savedKeyHint = ref('')
|
||||
const form = reactive({
|
||||
name: '',
|
||||
provider: 'openai',
|
||||
model_id: '',
|
||||
api_base_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
is_default: false,
|
||||
enabled: true,
|
||||
support_context: true,
|
||||
support_image: true,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0
|
||||
})
|
||||
|
||||
onMounted(loadModels)
|
||||
|
||||
async function loadModels() {
|
||||
const res = await api.get('/admin/models')
|
||||
models.value = res.data.data
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
testResult.value = null
|
||||
hasSavedKey.value = false
|
||||
savedKeyHint.value = ''
|
||||
Object.assign(form, {
|
||||
name: '', provider: 'openai', model_id: '', api_base_url: 'https://api.openai.com/v1',
|
||||
api_key: '', max_tokens: 4096, temperature: 0.7, is_default: false, enabled: true, support_context: true, support_image: true,
|
||||
frequency_penalty: 0, presence_penalty: 0
|
||||
})
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(m) {
|
||||
editingId.value = m.id
|
||||
testResult.value = null
|
||||
hasSavedKey.value = !!m.has_api_key
|
||||
savedKeyHint.value = m.api_key_hint || ''
|
||||
Object.assign(form, {
|
||||
name: m.name, provider: m.provider === 'dify' ? 'dify' : 'openai', model_id: m.model_id, api_base_url: m.api_base_url,
|
||||
api_key: '', max_tokens: m.max_tokens, temperature: parseFloat(m.temperature),
|
||||
is_default: !!m.is_default, enabled: !!m.enabled,
|
||||
support_context: m.support_context === undefined ? true : !!m.support_context,
|
||||
support_image: m.support_image === undefined ? true : !!m.support_image,
|
||||
frequency_penalty: m.frequency_penalty !== undefined && m.frequency_penalty !== null ? parseFloat(m.frequency_penalty) : 0,
|
||||
presence_penalty: m.presence_penalty !== undefined && m.presence_penalty !== null ? parseFloat(m.presence_penalty) : 0
|
||||
})
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveModel() {
|
||||
const payload = {
|
||||
...form,
|
||||
is_default: form.is_default ? 1 : 0,
|
||||
enabled: form.enabled ? 1 : 0,
|
||||
support_context: form.support_context ? 1 : 0,
|
||||
support_image: form.support_image ? 1 : 0
|
||||
}
|
||||
if (editingId.value) {
|
||||
if (!payload.api_key) delete payload.api_key
|
||||
await api.put(`/admin/models/${editingId.value}`, payload)
|
||||
} else {
|
||||
await api.post('/admin/models', payload)
|
||||
}
|
||||
showModal.value = false
|
||||
await loadModels()
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (confirm('确定删除此模型?')) {
|
||||
await api.delete(`/admin/models/${id}`)
|
||||
await loadModels()
|
||||
}
|
||||
}
|
||||
|
||||
async function testModel() {
|
||||
if (form.provider === 'dify') {
|
||||
if (!form.api_base_url) {
|
||||
testResult.value = { ok: false, message: '请填写 API 地址' }
|
||||
return
|
||||
}
|
||||
if (!form.api_key && !editingId.value) {
|
||||
testResult.value = { ok: false, message: '请填写 API Key' }
|
||||
return
|
||||
}
|
||||
if (!form.api_key && editingId.value && !hasSavedKey.value) {
|
||||
testResult.value = { ok: false, message: '当前模型未配置 Key,请填写' }
|
||||
return
|
||||
}
|
||||
} else if (!form.model_id || !form.api_base_url) {
|
||||
testResult.value = { ok: false, message: '请填写 Model ID 和 API 地址' }
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const payload = {
|
||||
provider: form.provider,
|
||||
model_id: form.model_id,
|
||||
api_base_url: form.api_base_url,
|
||||
temperature: form.temperature
|
||||
}
|
||||
if (form.api_key) payload.api_key = form.api_key
|
||||
|
||||
const url = editingId.value
|
||||
? `/admin/models/${editingId.value}/test`
|
||||
: '/admin/models/test'
|
||||
const res = await api.post(url, payload)
|
||||
testResult.value = { ok: true, ...res.data.data }
|
||||
} catch (e) {
|
||||
testResult.value = { ok: false, message: e.message }
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function quickTest(id) {
|
||||
testingId.value = id
|
||||
try {
|
||||
const res = await api.post(`/admin/models/${id}/test`, {})
|
||||
alert(`测试成功!\n延迟: ${res.data.data.latency_ms}ms\n回复: ${res.data.data.reply}`)
|
||||
} catch (e) {
|
||||
alert('测试失败: ' + e.message)
|
||||
} finally {
|
||||
testingId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.test-result.success {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: var(--success);
|
||||
}
|
||||
.test-result.error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: var(--danger);
|
||||
}
|
||||
.test-result p {
|
||||
margin-top: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.test-result.success p {
|
||||
color: #86efac;
|
||||
}
|
||||
.reply-preview {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field-hint-warn {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.saved-key-hint {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.saved-key-hint code {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user