Files
X-Financial/web/src/views/scripts/LlmSettingsPanel.js
caoxiaozhu 5753899eb3 feat(web): 主题皮肤系统与 LLM 设置面板重构
- useThemeSkin 重构主题皮肤应用逻辑,支持企业 AI 风格主题切换
- settingsModelHelper 新增主题与模型表字段映射,useSettings 适配
- LlmSettingsPanel/SettingsView 面板重构,支持多模型行编辑与主题区块
- settings-view.css 适配主题样式,新增 settings-theme-section 测试,更新 settings-llm-section 测试
2026-06-26 22:42:00 +08:00

240 lines
6.6 KiB
JavaScript

import { computed, ref } from 'vue'
import { testModelConnectivity } from '../../services/settings.js'
import { useToast } from '../../composables/useToast.js'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import {
CUSTOM_OPENAI_PROVIDER,
MODEL_SECRET_MASK,
MODEL_TYPE_LABELS,
MODEL_TYPE_OPTIONS,
getProviderEndpoint,
getRerankerEndpoint,
isModelSecretMask,
normalizeLlmModelRows,
normalizeProviderValue,
normalizeValue
} from '../../utils/settingsModelHelper.js'
const FIXED_MODEL_SLOTS = new Set(['main', 'backup', 'embedding', 'reranker'])
const MODEL_TYPE_CAPABILITY = Object.fromEntries(
MODEL_TYPE_OPTIONS.map((option) => [option.value, option.capability])
)
function buildEmptyModelDraft() {
return {
slot: '',
provider: CUSTOM_OPENAI_PROVIDER,
url: '',
apiKey: '',
apiKeyConfigured: false,
modelId: '',
type: 'llm'
}
}
function generateModelSlot(type) {
const prefix = type === 'embedding' ? 'embedding' : type === 'rerank' ? 'rerank' : 'llm'
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
return `${prefix}_${suffix}`
}
function normalizeDraftModel(draft) {
return normalizeLlmModelRows([
{
slot: draft.slot || generateModelSlot(draft.type),
provider: draft.provider,
url: draft.url,
apiKey: draft.apiKey,
apiKeyConfigured: draft.apiKeyConfigured,
modelId: draft.modelId,
type: draft.type
}
])[0]
}
export default {
name: 'LlmSettingsPanel',
components: {
EnterpriseSelect
},
props: {
llmForm: {
type: Object,
required: true
},
providerOptions: {
type: Array,
required: true
}
},
setup(props) {
const { toast } = useToast()
const modelTestState = ref({})
const modelDialogOpen = ref(false)
const editingSlot = ref('')
const modelDraft = ref(buildEmptyModelDraft())
const modelRows = computed(() => normalizeLlmModelRows(props.llmForm.models))
const isEditingFixedModel = computed(() => isFixedModelSlot(editingSlot.value))
function replaceModelRows(rows) {
props.llmForm.models = normalizeLlmModelRows(rows)
}
function getModelTypeLabel(type) {
return MODEL_TYPE_LABELS[type] || MODEL_TYPE_LABELS.llm
}
function isFixedModelSlot(slot) {
return FIXED_MODEL_SLOTS.has(String(slot || ''))
}
function getModelTestState(slot) {
return modelTestState.value[slot] || { status: 'idle', message: '' }
}
function isModelTesting(slot) {
return getModelTestState(slot).status === 'testing'
}
function openAddModelDialog() {
editingSlot.value = ''
modelDraft.value = buildEmptyModelDraft()
modelDialogOpen.value = true
}
function openEditModelDialog(model) {
editingSlot.value = model.slot
modelDraft.value = { ...model }
modelDialogOpen.value = true
}
function closeModelDialog() {
modelDialogOpen.value = false
editingSlot.value = ''
modelDraft.value = buildEmptyModelDraft()
}
function applyProviderPresetToDraft() {
const provider = normalizeProviderValue(modelDraft.value.provider, CUSTOM_OPENAI_PROVIDER)
modelDraft.value.provider = provider
modelDraft.value.url =
modelDraft.value.type === 'rerank' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
}
function selectDraftModelType(type) {
if (isEditingFixedModel.value) {
return
}
modelDraft.value.type = type
applyProviderPresetToDraft()
}
function clearDraftSecretMask() {
if (isModelSecretMask(modelDraft.value.apiKey)) {
modelDraft.value.apiKey = ''
}
}
function validateDraftModel() {
const provider = normalizeValue(modelDraft.value.provider)
const url = normalizeValue(modelDraft.value.url)
const modelId = normalizeValue(modelDraft.value.modelId)
if (!provider || !url || !modelId) {
toast('请完整填写供应商、接口地址和 model_id。')
return false
}
return true
}
function saveModelDialog() {
if (!validateDraftModel()) {
return
}
const nextModel = normalizeDraftModel(modelDraft.value)
const rows = [...modelRows.value]
const currentIndex = rows.findIndex((model) => model.slot === editingSlot.value)
if (currentIndex >= 0) {
rows.splice(currentIndex, 1, nextModel)
} else {
rows.push(nextModel)
}
replaceModelRows(rows)
closeModelDialog()
}
function removeModel(model) {
if (isFixedModelSlot(model.slot)) {
toast('内置运行时槽位不能删除。')
return
}
if (typeof window !== 'undefined' && !window.confirm('确定删除这个模型配置吗?')) {
return
}
replaceModelRows(modelRows.value.filter((row) => row.slot !== model.slot))
}
async function testModelConnection(model) {
if (!normalizeValue(model.provider) || !normalizeValue(model.modelId) || !normalizeValue(model.url)) {
const message = '请先完整填写模型的供应商、model_id 和接口地址。'
modelTestState.value[model.slot] = { status: 'error', message }
toast(message)
return
}
modelTestState.value[model.slot] = { status: 'testing', message: '正在测试模型连通性...' }
const payload = {
provider: model.provider,
model: model.modelId,
endpoint: model.url,
api_key: model.apiKey === MODEL_SECRET_MASK ? '' : model.apiKey,
capability: MODEL_TYPE_CAPABILITY[model.type] || 'chat',
slot: model.slot
}
try {
const result = await testModelConnectivity(payload)
modelTestState.value[model.slot] = {
status: result.ok ? 'success' : 'error',
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
}
toast(modelTestState.value[model.slot].message)
} catch (error) {
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
modelTestState.value[model.slot] = { status: 'error', message }
toast(message)
}
}
return {
MODEL_TYPE_OPTIONS,
applyProviderPresetToDraft,
clearDraftSecretMask,
closeModelDialog,
getModelTestState,
getModelTypeLabel,
isEditingFixedModel,
isFixedModelSlot,
isModelTesting,
modelDialogOpen,
modelDraft,
modelRows,
openAddModelDialog,
openEditModelDialog,
removeModel,
saveModelDialog,
selectDraftModelType,
testModelConnection
}
}
}