245 lines
10 KiB
Vue
245 lines
10 KiB
Vue
<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>
|