Files
jizhi/admin-web/src/views/ModelService.vue
T

245 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { CloudServerOutlined, KeyOutlined } from '@ant-design/icons-vue'
import { api } from '../api'
import { readError } from '../composables/useAdminConfigs'
interface ApiKeyState {
configured: boolean
masked: string
source: 'database' | 'environment' | 'none'
canManage: boolean
}
interface LlmSettings {
protocol: string
baseUrl: string
model: string
maxTokens: number
temperature: number
apiKey: ApiKeyState
}
const loading = ref(true)
const saving = ref(false)
const testing = ref(false)
const savingKey = ref(false)
const deletingKey = ref(false)
const apiKeyInput = ref('')
const testResult = ref<{ ok: boolean; detail: string } | null>(null)
const keyState = ref<ApiKeyState>({ configured: false, masked: '', source: 'none', canManage: false })
const form = reactive({
protocol: 'responses',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
maxTokens: 1024,
temperature: 0.7,
})
const protocols = [
{ value: 'responses', label: 'Responses', detail: 'OpenAI 新版接口,支持 Agent 工具调用' },
{ value: 'chat_completions', label: 'Chat Completions', detail: '兼容 OpenAI 风格的聊天补全服务' },
{ value: 'messages', label: 'Messages', detail: '兼容 Anthropic Claude Messages API' },
]
onMounted(load)
async function load() {
loading.value = true
try { apply(await api.llmSettings()) }
catch (error: any) { message.error(readError(error, '模型配置加载失败')) }
finally { loading.value = false }
}
function apply(settings: LlmSettings) {
form.protocol = settings.protocol || 'responses'
form.baseUrl = settings.baseUrl || 'https://api.openai.com/v1'
form.model = settings.model || 'gpt-4o-mini'
form.maxTokens = settings.maxTokens ?? 1024
form.temperature = settings.temperature ?? 0.7
keyState.value = settings.apiKey
}
async function saveSettings(showSuccess = true) {
saving.value = true
try {
apply(await api.updateLlmSettings({ ...form }))
if (showSuccess) message.success('模型参数已保存')
} catch (error: any) {
message.error(readError(error, '模型参数保存失败'))
throw error
} finally { saving.value = false }
}
async function saveApiKey() {
if (!apiKeyInput.value.trim()) {
message.warning('请输入新的 API Key')
return
}
savingKey.value = true
try {
apply(await api.updateLlmApiKey(apiKeyInput.value))
apiKeyInput.value = ''
message.success('API Key 已加密保存')
} catch (error: any) {
message.error(readError(error, 'API Key 保存失败'))
} finally { savingKey.value = false }
}
async function deleteApiKey() {
deletingKey.value = true
try {
apply(await api.deleteLlmApiKey())
message.success(keyState.value.source === 'environment'
? '数据库密钥已删除,当前回退使用环境变量'
: 'API Key 已删除')
} catch (error: any) {
message.error(readError(error, 'API Key 删除失败'))
} finally { deletingKey.value = false }
}
async function testConnection() {
testing.value = true
testResult.value = null
try {
await saveSettings(false)
const result = await api.testLlm()
testResult.value = { ok: result.ok, detail: result.detail || result.error || '' }
result.ok ? message.success('LLM 连接正常') : message.error(result.error || '连接失败')
} catch (error: any) {
testResult.value = { ok: false, detail: readError(error, '连接失败') }
} finally { testing.value = false }
}
function sourceLabel(source: ApiKeyState['source']) {
if (source === 'database') return '后台加密配置'
if (source === 'environment') return '环境变量回退'
return '未配置'
}
</script>
<template>
<div class="settings-page">
<header class="page-heading">
<div>
<h1>模型服务</h1>
<p>配置 AI 服务连接模型参数和访问密钥测试连接会先保存当前模型参数</p>
</div>
<a-space>
<a-button :loading="testing" @click="testConnection">测试连接</a-button>
<a-button type="primary" :loading="saving" @click="saveSettings()">保存参数</a-button>
</a-space>
</header>
<a-skeleton v-if="loading" active :paragraph="{ rows: 9 }" />
<template v-else>
<a-alert
v-if="testResult"
:type="testResult.ok ? 'success' : 'error'"
:message="testResult.ok ? '连接成功' : '连接失败'"
:description="testResult.detail"
show-icon
closable
class="result-alert"
@close="testResult = null" />
<section class="settings-panel" aria-labelledby="connection-title">
<div class="section-heading">
<CloudServerOutlined />
<div><h2 id="connection-title">连接与生成参数</h2><p>这些参数用于通用对话OCR 等识别任务继续使用各自的低温配置</p></div>
</div>
<a-form layout="vertical" class="form-grid">
<a-form-item label="API 协议" class="span-full">
<a-radio-group v-model:value="form.protocol" class="protocol-grid">
<label v-for="protocol in protocols" :key="protocol.value" class="protocol-option">
<a-radio :value="protocol.value" />
<span><strong>{{ protocol.label }}</strong><small>{{ protocol.detail }}</small></span>
</label>
</a-radio-group>
</a-form-item>
<a-form-item label="API 地址" class="span-full" extra="填写服务根地址,系统会按协议自动追加请求路径。">
<a-input v-model:value="form.baseUrl" placeholder="https://api.openai.com/v1" />
</a-form-item>
<a-form-item label="模型名称">
<a-input v-model:value="form.model" placeholder="gpt-4o-mini" />
</a-form-item>
<a-form-item label="最大输出 Token" extra="单次通用回复的最大输出量,范围 64–4096。">
<a-input-number v-model:value="form.maxTokens" :min="64" :max="4096" style="width:100%" />
</a-form-item>
<a-form-item label="温度" extra="0 更稳定,数值越高回复越灵活;默认 0.7。">
<a-input-number v-model:value="form.temperature" :min="0" :max="2" :step="0.1" style="width:100%" />
</a-form-item>
</a-form>
</section>
<section class="settings-panel key-panel" aria-labelledby="key-title">
<div class="section-heading key-heading">
<KeyOutlined />
<div><h2 id="key-title">API Key</h2><p>密钥保存后只显示尾号完整值不会通过后台接口返回</p></div>
<a-tag :color="keyState.configured ? 'green' : 'default'">{{ sourceLabel(keyState.source) }}</a-tag>
</div>
<div class="key-status">
<span class="status-label">当前密钥</span>
<strong>{{ keyState.configured ? keyState.masked : '尚未配置' }}</strong>
</div>
<template v-if="keyState.canManage">
<div class="key-editor">
<a-input-password
v-model:value="apiKeyInput"
autocomplete="new-password"
placeholder="输入新的 API Key,保存后将替换当前密钥"
@press-enter="saveApiKey" />
<a-button type="primary" :loading="savingKey" @click="saveApiKey">保存密钥</a-button>
<a-popconfirm
v-if="keyState.source === 'database'"
title="删除后台保存的密钥?"
description="删除后将回退使用 LLM_API_KEY;若未配置环境变量,AI 服务会停用。"
ok-text="删除"
cancel-text="取消"
@confirm="deleteApiKey">
<a-button danger :loading="deletingKey">删除</a-button>
</a-popconfirm>
</div>
<p class="key-note">服务端需配置 <code>Secrets__EncryptionKey</code> 才能加密保存值为 Base64 编码的 32 字节密钥</p>
</template>
<a-alert v-else type="info" message="只有超级管理员可以替换或删除 API Key。" show-icon />
</section>
</template>
</div>
</template>
<style scoped>
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
.page-heading h1 { margin: 0 0 6px; font-size: 24px; line-height: 1.3; }
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; background: #fff; }
.settings-panel + .settings-panel { margin-top: 18px; }
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 22px; }
.section-heading > :first-child { margin-top: 3px; color: #5b6bf5; font-size: 20px; }
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.span-full { grid-column: 1 / -1; }
.protocol-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; width: 100%; }
.protocol-option { display: flex; align-items: flex-start; gap: 4px; min-height: 82px; padding: 14px; border: 1px solid #e4e7eb; border-radius: 12px; cursor: pointer; transition: border-color .18s ease-out, background-color .18s ease-out; }
.protocol-option:hover { border-color: #9ba5fa; background: #f8f9ff; }
.protocol-option:has(.ant-radio-checked) { border-color: #5b6bf5; background: #f3f4ff; }
.protocol-option strong, .protocol-option small { display: block; }
.protocol-option small { margin-top: 4px; color: #5e6772; line-height: 1.45; }
.key-heading { align-items: center; }
.key-heading > div { flex: 1; }
.key-status { display: flex; align-items: baseline; gap: 18px; padding: 14px 16px; margin-bottom: 16px; border-radius: 12px; background: #f6f7f9; }
.status-label { color: #5e6772; }
.key-status strong { font-variant-numeric: tabular-nums; letter-spacing: .04em; }
.key-editor { display: grid; grid-template-columns: minmax(240px, 1fr) auto auto; gap: 10px; }
.key-note { margin: 10px 0 0; color: #5e6772; font-size: 12px; }
.key-note code { color: #414eb8; }
.result-alert { margin-bottom: 18px; }
@media (max-width: 760px) {
.page-heading { flex-direction: column; }
.form-grid, .protocol-grid, .key-editor { grid-template-columns: 1fr; }
.settings-panel { padding: 18px; }
}
</style>