feat(web): 主题皮肤系统与 LLM 设置面板重构
- useThemeSkin 重构主题皮肤应用逻辑,支持企业 AI 风格主题切换 - settingsModelHelper 新增主题与模型表字段映射,useSettings 适配 - LlmSettingsPanel/SettingsView 面板重构,支持多模型行编辑与主题区块 - settings-view.css 适配主题样式,新增 settings-theme-section 测试,更新 settings-llm-section 测试
This commit is contained in:
@@ -1,103 +1,55 @@
|
||||
import { ref } from 'vue'
|
||||
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 MODEL_SECRET_MASK = '********'
|
||||
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])
|
||||
)
|
||||
|
||||
const MODEL_TEST_CONFIGS = {
|
||||
main: {
|
||||
label: '主模型',
|
||||
providerKey: 'mainProvider',
|
||||
modelKey: 'mainModel',
|
||||
endpointKey: 'mainEndpoint',
|
||||
apiKeyKey: 'mainApiKey',
|
||||
capability: 'chat'
|
||||
},
|
||||
backup: {
|
||||
label: '备份模型',
|
||||
providerKey: 'backupProvider',
|
||||
modelKey: 'backupModel',
|
||||
endpointKey: 'backupEndpoint',
|
||||
apiKeyKey: 'backupApiKey',
|
||||
capability: 'chat'
|
||||
},
|
||||
embedding: {
|
||||
label: 'Embedding 模型',
|
||||
providerKey: 'embeddingProvider',
|
||||
modelKey: 'embeddingModel',
|
||||
endpointKey: 'embeddingEndpoint',
|
||||
apiKeyKey: 'embeddingApiKey',
|
||||
capability: 'embedding'
|
||||
},
|
||||
reranker: {
|
||||
label: 'Reranker 模型',
|
||||
providerKey: 'rerankerProvider',
|
||||
modelKey: 'rerankerModel',
|
||||
endpointKey: 'rerankerEndpoint',
|
||||
apiKeyKey: 'rerankerApiKey',
|
||||
capability: 'reranker'
|
||||
function buildEmptyModelDraft() {
|
||||
return {
|
||||
slot: '',
|
||||
provider: CUSTOM_OPENAI_PROVIDER,
|
||||
url: '',
|
||||
apiKey: '',
|
||||
apiKeyConfigured: false,
|
||||
modelId: '',
|
||||
type: 'llm'
|
||||
}
|
||||
}
|
||||
|
||||
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
|
||||
|
||||
const PROVIDER_ENDPOINTS = {
|
||||
MiniMax: 'https://api.minimaxi.com/v1',
|
||||
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
|
||||
Kimi: 'https://api.moonshot.ai/v1',
|
||||
Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
Codex: 'https://api.openai.com/v1',
|
||||
Claude: 'https://api.anthropic.com/v1/',
|
||||
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
||||
[CUSTOM_OPENAI_PROVIDER]: ''
|
||||
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}`
|
||||
}
|
||||
|
||||
const RERANKER_PROVIDER_ENDPOINTS = {
|
||||
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
|
||||
[CUSTOM_OPENAI_PROVIDER]: ''
|
||||
}
|
||||
|
||||
const LEGACY_PROVIDER_MAP = {
|
||||
'OpenAI Compatible': 'Codex',
|
||||
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
|
||||
Ollama: CUSTOM_OPENAI_PROVIDER,
|
||||
'自定义网关': CUSTOM_OPENAI_PROVIDER
|
||||
}
|
||||
|
||||
function normalizeValue(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function normalizeProviderValue(value, fallback = 'Codex') {
|
||||
const normalized = normalizeValue(value)
|
||||
|
||||
const providerOptions = Object.keys(PROVIDER_ENDPOINTS)
|
||||
if (providerOptions.includes(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
if (LEGACY_PROVIDER_MAP[normalized]) {
|
||||
return LEGACY_PROVIDER_MAP[normalized]
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function getProviderEndpoint(provider) {
|
||||
return PROVIDER_ENDPOINTS[provider] ?? ''
|
||||
}
|
||||
|
||||
function getRerankerEndpoint(provider) {
|
||||
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
|
||||
}
|
||||
|
||||
function isModelConfigReady(provider, model, endpoint) {
|
||||
return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint))
|
||||
}
|
||||
|
||||
function isModelSecretMask(value) {
|
||||
return value === MODEL_SECRET_MASK
|
||||
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 {
|
||||
@@ -117,81 +69,170 @@ export default {
|
||||
},
|
||||
setup(props) {
|
||||
const { toast } = useToast()
|
||||
const modelTestState = ref({
|
||||
main: { status: 'idle', message: '' },
|
||||
backup: { status: 'idle', message: '' },
|
||||
embedding: { status: 'idle', message: '' },
|
||||
reranker: { status: 'idle', message: '' }
|
||||
})
|
||||
const modelTestState = ref({})
|
||||
const modelDialogOpen = ref(false)
|
||||
const editingSlot = ref('')
|
||||
const modelDraft = ref(buildEmptyModelDraft())
|
||||
|
||||
function applyProviderPreset(testKey) {
|
||||
const config = MODEL_TEST_CONFIGS[testKey]
|
||||
const provider = normalizeProviderValue(props.llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER)
|
||||
const modelRows = computed(() => normalizeLlmModelRows(props.llmForm.models))
|
||||
const isEditingFixedModel = computed(() => isFixedModelSlot(editingSlot.value))
|
||||
|
||||
props.llmForm[config.providerKey] = provider
|
||||
props.llmForm[config.endpointKey] =
|
||||
testKey === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
|
||||
function replaceModelRows(rows) {
|
||||
props.llmForm.models = normalizeLlmModelRows(rows)
|
||||
}
|
||||
|
||||
function getModelTestState(testKey) {
|
||||
return modelTestState.value[testKey] || { status: 'idle', message: '' }
|
||||
function getModelTypeLabel(type) {
|
||||
return MODEL_TYPE_LABELS[type] || MODEL_TYPE_LABELS.llm
|
||||
}
|
||||
|
||||
function isModelTesting(testKey) {
|
||||
return getModelTestState(testKey).status === 'testing'
|
||||
function isFixedModelSlot(slot) {
|
||||
return FIXED_MODEL_SLOTS.has(String(slot || ''))
|
||||
}
|
||||
|
||||
function clearModelSecretMask(testKey) {
|
||||
const config = MODEL_TEST_CONFIGS[testKey]
|
||||
if (isModelSecretMask(props.llmForm[config.apiKeyKey])) {
|
||||
props.llmForm[config.apiKeyKey] = ''
|
||||
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 = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function testModelConnection(testKey) {
|
||||
const config = MODEL_TEST_CONFIGS[testKey]
|
||||
const provider = props.llmForm[config.providerKey]
|
||||
const model = props.llmForm[config.modelKey]
|
||||
const endpoint = props.llmForm[config.endpointKey]
|
||||
const apiKey = props.llmForm[config.apiKeyKey]
|
||||
function validateDraftModel() {
|
||||
const provider = normalizeValue(modelDraft.value.provider)
|
||||
const url = normalizeValue(modelDraft.value.url)
|
||||
const modelId = normalizeValue(modelDraft.value.modelId)
|
||||
|
||||
if (!isModelConfigReady(provider, model, endpoint)) {
|
||||
const message = `请先完整填写${config.label}的供应商、模型名称和接口地址。`
|
||||
modelTestState.value[testKey] = { status: 'error', message }
|
||||
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[testKey] = { status: 'testing', message: '正在测试模型连通性...' }
|
||||
modelTestState.value[model.slot] = { status: 'testing', message: '正在测试模型连通性...' }
|
||||
|
||||
const payload = {
|
||||
provider,
|
||||
model,
|
||||
endpoint,
|
||||
api_key: isModelSecretMask(apiKey) ? '' : apiKey,
|
||||
capability: config.capability,
|
||||
slot: testKey
|
||||
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[testKey] = {
|
||||
modelTestState.value[model.slot] = {
|
||||
status: result.ok ? 'success' : 'error',
|
||||
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
|
||||
}
|
||||
toast(modelTestState.value[testKey].message)
|
||||
toast(modelTestState.value[model.slot].message)
|
||||
} catch (error) {
|
||||
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
|
||||
modelTestState.value[testKey] = { status: 'error', message }
|
||||
modelTestState.value[model.slot] = { status: 'error', message }
|
||||
toast(message)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applyProviderPreset,
|
||||
MODEL_TYPE_OPTIONS,
|
||||
applyProviderPresetToDraft,
|
||||
clearDraftSecretMask,
|
||||
closeModelDialog,
|
||||
getModelTestState,
|
||||
getModelTypeLabel,
|
||||
isEditingFixedModel,
|
||||
isFixedModelSlot,
|
||||
isModelTesting,
|
||||
clearModelSecretMask,
|
||||
modelDialogOpen,
|
||||
modelDraft,
|
||||
modelRows,
|
||||
openAddModelDialog,
|
||||
openEditModelDialog,
|
||||
removeModel,
|
||||
saveModelDialog,
|
||||
selectDraftModelType,
|
||||
testModelConnection
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user