feat(settings): refactor LLM config to table inline-edit UI

- Remove old card-based model list UI
- Add LLM config state management (expandedRow, editingSnapshot)
- Implement addModel/removeModel with embedding/rerank constraints
- Implement updateModel, testModel, saveModel, toggleRow, cancelEdit
- Add showRequiredWarning computed property
- Rewrite template with 4 LLM type sections using LLMTableRow
- Add styles for llm-type-section, warning-bar, etc.
- Update loadSettings to initialize with empty arrays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 11:33:42 +08:00
parent 99c30d9534
commit 7bbaf67591

View File

@@ -2,20 +2,31 @@
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig, type LLMProvider } from '@/api/settings' import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig, type LLMProvider } from '@/api/settings'
import LLMTableRow from '@/components/settings/LLMTableRow.vue' import LLMTableRow from '@/components/settings/LLMTableRow.vue'
import { Save, Eye, EyeOff, Play, RotateCcw, Plus, Trash2, X, Check } from 'lucide-vue-next' import { Save, RotateCcw, Plus } from 'lucide-vue-next'
// 状态 // 状态
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const showApiKey = ref<Record<string, boolean>>({})
const savingModel = ref<string | null>(null) // 当前正在保存的模型 key const savingModel = ref<string | null>(null) // 当前正在保存的模型 key
const modelSaveSuccess = ref<string | null>(null) // 刚刚保存成功的模型 key
const toast = ref<{ show: boolean; message: string; type: 'success' | 'error' }>({ const toast = ref<{ show: boolean; message: string; type: 'success' | 'error' }>({
show: false, show: false,
message: '', message: '',
type: 'success' type: 'success'
}) })
// 展开的行
const expandedRow = ref<string | null>(null) // 'chat-0', 'vlm-0' 等
// 当前正在编辑的模型快照(用于取消时恢复)
const editingSnapshot = ref<{ type: string; index: number; data: LLMModelConfig } | null>(null)
// 必填警告
const showRequiredWarning = computed(() => {
return llmConfig.value.chat.length === 0 ||
llmConfig.value.embedding.length === 0 ||
llmConfig.value.rerank.length === 0
})
// 用户资料 // 用户资料
const profile = ref({ const profile = ref({
email: '', email: '',
@@ -69,11 +80,6 @@ function isModelDirty(type: string, index: number): boolean {
return JSON.stringify(original) !== JSON.stringify(current) return JSON.stringify(original) !== JSON.stringify(current)
} }
// 获取模型唯一标识
function getModelKey(type: string, index: number): string {
return `${type}-${index}`
}
// 创建空的模型配置 // 创建空的模型配置
function createEmptyModel(type: string): LLMModelConfig { function createEmptyModel(type: string): LLMModelConfig {
return { return {
@@ -91,19 +97,64 @@ function addModel(type: string) {
if (!llmConfig.value[type as keyof LLMConfig]) { if (!llmConfig.value[type as keyof LLMConfig]) {
llmConfig.value[type as keyof LLMConfig] = [] llmConfig.value[type as keyof LLMConfig] = []
} }
llmConfig.value[type as keyof LLMConfig]!.push(createEmptyModel(type)) // embedding/rerank 最多 1 个
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
return
}
const newModel = createEmptyModel(type)
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
// 自动展开新添加的行
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
expandedRow.value = getRowKey(type, newIndex)
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) }
} }
// 删除模型 // 删除模型
function removeModel(type: string, index: number) { function removeModel(type: string, index: number) {
// embedding/rerank 为知识库必填,至少保留 1 个
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
return
}
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1) llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
expandedRow.value = null
editingSnapshot.value = null
} }
// 复制模型 // 行标识
function duplicateModel(type: string, index: number) { function getRowKey(type: string, index: number): string {
const model = llmConfig.value[type as keyof LLMConfig]![index] return `${type}-${index}`
const copy = { ...model, name: `${model.name}-copy-${Date.now()}` } }
llmConfig.value[type as keyof LLMConfig]!.push(copy)
// 切换行展开
function toggleRow(type: string, index: number, model: LLMModelConfig) {
const key = getRowKey(type, index)
if (expandedRow.value === key) {
expandedRow.value = null
editingSnapshot.value = null
} else {
// 保存快照用于取消
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) }
expandedRow.value = key
}
}
// 取消编辑
function cancelEdit(type: string, index: number) {
if (editingSnapshot.value && editingSnapshot.value.type === type && editingSnapshot.value.index === index) {
// 恢复原始数据
llmConfig.value[type as keyof LLMConfig]![index] = editingSnapshot.value.data
}
expandedRow.value = null
editingSnapshot.value = null
}
// 更新模型
function updateModel(type: string, index: number, model: LLMModelConfig) {
llmConfig.value[type as keyof LLMConfig]![index] = model
} }
// 加载设置 // 加载设置
@@ -119,16 +170,15 @@ async function loadSettings() {
originalProfile.value = { ...profile.value } originalProfile.value = { ...profile.value }
// 加载 LLM 配置 // 加载 LLM 配置
if (res.data.llm_config && Object.keys(res.data.llm_config).length > 0) { if (res.data.llm_config) {
llmConfig.value = res.data.llm_config as LLMConfig
} else {
// 默认添加一个空配置
llmConfig.value = { llmConfig.value = {
chat: [createEmptyModel('chat')], chat: res.data.llm_config.chat || [],
vlm: [createEmptyModel('vlm')], vlm: res.data.llm_config.vlm || [],
embedding: [createEmptyModel('embedding')], embedding: res.data.llm_config.embedding || [],
rerank: [createEmptyModel('rerank')] rerank: res.data.llm_config.rerank || []
} }
} else {
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
} }
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value)) originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
@@ -180,7 +230,7 @@ async function saveLLM() {
// 保存单个模型 // 保存单个模型
async function saveModel(type: string, index: number) { async function saveModel(type: string, index: number) {
const key = getModelKey(type, index) const key = getRowKey(type, index)
savingModel.value = key savingModel.value = key
try { try {
// 发送完整的配置(包含该类型的所有模型) // 发送完整的配置(包含该类型的所有模型)
@@ -189,13 +239,10 @@ async function saveModel(type: string, index: number) {
// 更新原始配置(深拷贝当前完整配置) // 更新原始配置(深拷贝当前完整配置)
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value)) originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
// 显示成功状态 // 关闭展开的行
modelSaveSuccess.value = key expandedRow.value = null
setTimeout(() => { editingSnapshot.value = null
if (modelSaveSuccess.value === key) { showToast('保存成功')
modelSaveSuccess.value = null
}
}, 1500)
} catch (e: unknown) { } catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败' const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
showToast(msg, 'error') showToast(msg, 'error')
@@ -205,15 +252,19 @@ async function saveModel(type: string, index: number) {
} }
// 测试 LLM 连接 // 测试 LLM 连接
async function testLLM(type: string, config: LLMModelConfig) { async function testModel(type: string, index: number, model: LLMModelConfig) {
try { try {
const res = await settingsApi.testLLM({ type: type as any, ...config }) const res = await settingsApi.testLLM({ type: type as any, ...model })
if (res.data.success) { if (res.data.success) {
showToast(`连接成功: ${res.data.message}`) // 测试通过,标记为可用
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
showToast('连接成功')
} else { } else {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast(`连接失败: ${res.data.error}`, 'error') showToast(`连接失败: ${res.data.error}`, 'error')
} }
} catch (e) { } catch (e) {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast('测试连接失败', 'error') showToast('测试连接失败', 'error')
} }
} }
@@ -273,8 +324,6 @@ function showToast(message: string, type: 'success' | 'error' = 'success') {
}, 3000) }, 3000)
} }
// LLM 类型列表
const llmTypes = ['chat', 'vlm', 'embedding', 'rerank'] as const
// 获取模型显示名称 // 获取模型显示名称
function getModelName(config: LLMModelConfig): string { function getModelName(config: LLMModelConfig): string {
@@ -344,133 +393,109 @@ onMounted(loadSettings)
</div> </div>
<!-- LLM Config Section --> <!-- LLM Config Section -->
<div v-for="type in llmTypes" :key="type" class="settings-card"> <div class="settings-card">
<div class="card-header"> <div class="card-header">
<span class="card-title">{{ type.toUpperCase() }}</span> <span class="card-title">// LLM CONFIGURATION</span>
<button class="add-btn" @click="addModel(type)">
<Plus :size="12" /> 添加模型
</button>
</div> </div>
<!-- 模型列表 --> <!-- 必填警告 -->
<div v-if="llmConfig[type] && llmConfig[type]!.length > 0" class="model-list"> <div v-if="showRequiredWarning" class="warning-bar">
<div chat / embedding / rerank 为知识库必填请确保已配置
v-for="(model, index) in llmConfig[type]" </div>
:key="index"
class="model-item"
:class="{ disabled: !model.enabled }"
>
<div class="model-header">
<div class="model-name-group">
<input
v-model="model.name"
type="text"
class="model-name-input"
placeholder="模型名称"
/>
<label class="model-enabled">
<input type="checkbox" v-model="model.enabled" />
<span class="checkbox-custom"></span>
启用
</label>
</div>
<div class="model-actions">
<button
class="icon-btn"
:class="{
'has-changes': isModelDirty(type, index),
'is-saving': savingModel === getModelKey(type, index),
'is-saved': modelSaveSuccess === getModelKey(type, index)
}"
@click="saveModel(type, index)"
:title="isModelDirty(type, index) ? '保存更改' : '无更改'"
:disabled="savingModel === getModelKey(type, index)"
>
<div v-if="savingModel === getModelKey(type, index)" class="btn-spinner-sm"></div>
<Check v-else-if="modelSaveSuccess === getModelKey(type, index)" :size="12" />
<Save v-else :size="12" />
<span v-if="isModelDirty(type, index) && modelSaveSuccess !== getModelKey(type, index)" class="unsaved-dot"></span>
</button>
<button class="icon-btn danger" @click="removeModel(type, index)" title="删除">
<Trash2 :size="12" />
</button>
</div>
</div>
<div class="form-row"> <!-- Chat Section -->
<div class="form-group"> <div class="llm-type-section">
<label class="form-label">// PROVIDER</label> <div class="llm-type-header">
<select <span class="llm-type-title">CHAT</span>
v-model="model.provider" <button class="add-btn" @click="addModel('chat')">
class="form-select" <Plus :size="12" /> 添加
@change="onProviderChange(model)" </button>
> </div>
<option value="openai">OpenAI</option> <div v-if="llmConfig.chat && llmConfig.chat.length > 0" class="model-table">
<option value="claude">Claude</option> <div v-for="(model, index) in llmConfig.chat" :key="index">
<option value="ollama">Ollama</option> <LLMTableRow
<option value="deepseek">DeepSeek</option> :model="model"
<option value="custom">Custom</option> :is-expanded="expandedRow === getRowKey('chat', index)"
</select> @toggle="toggleRow('chat', index, model)"
</div> @update="(m) => updateModel('chat', index, m)"
<div class="form-group"> @delete="removeModel('chat', index)"
<label class="form-label">// MODEL</label> @test="(m) => testModel('chat', index, m)"
<input v-model="model.model" type="text" class="form-input" placeholder="gpt-4o" />
</div>
</div>
<div class="form-group">
<label class="form-label">// BASE URL</label>
<input
v-model="model.base_url"
type="text"
class="form-input"
:placeholder="getDefaultBaseUrl(model.provider)"
/> />
</div> </div>
</div>
<div v-else class="empty-state">暂无 chat 模型配置</div>
</div>
<div class="form-group"> <!-- VLM Section -->
<label class="form-label">// API KEY</label> <div class="llm-type-section">
<div class="input-with-toggle"> <div class="llm-type-header">
<input <span class="llm-type-title">VLM <span class="optional-tag">(可选)</span></span>
v-model="model.api_key" <button class="add-btn" @click="addModel('vlm')">
:type="showApiKey[`${type}-${index}`] ? 'text' : 'password'" <Plus :size="12" /> 添加
class="form-input" </button>
placeholder="sk-..." </div>
/> <div v-if="llmConfig.vlm && llmConfig.vlm.length > 0" class="model-table">
<button class="toggle-visibility" @click="showApiKey[`${type}-${index}`] = !showApiKey[`${type}-${index}`]"> <div v-for="(model, index) in llmConfig.vlm" :key="index">
<Eye v-if="!showApiKey[`${type}-${index}`]" :size="14" /> <LLMTableRow
<EyeOff v-else :size="14" /> :model="model"
</button> :is-expanded="expandedRow === getRowKey('vlm', index)"
</div> @toggle="toggleRow('vlm', index, model)"
</div> @update="(m) => updateModel('vlm', index, m)"
@delete="removeModel('vlm', index)"
<div class="model-footer"> @test="(m) => testModel('vlm', index, m)"
<button class="test-btn" @click="testLLM(type, model)"> />
<Play :size="12" /> 测试连接
</button>
</div> </div>
</div> </div>
<div v-else class="empty-state">暂无 vlm 模型配置</div>
</div> </div>
<!-- 空状态 --> <!-- Embedding Section -->
<div v-else class="empty-state"> <div class="llm-type-section">
<span>暂无模型配置</span> <div class="llm-type-header">
<button class="add-btn" @click="addModel(type)"> <span class="llm-type-title">EMBEDDING <span class="required-tag">(知识库)</span></span>
<Plus :size="12" /> 添加第一个模型 <button class="add-btn" @click="addModel('embedding')">
</button> <Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.embedding && llmConfig.embedding.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.embedding" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('embedding', index)"
@toggle="toggleRow('embedding', index, model)"
@update="(m) => updateModel('embedding', index, m)"
@delete="removeModel('embedding', index)"
@test="(m) => testModel('embedding', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 embedding 模型配置</div>
</div>
<!-- Rerank Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">RERANK <span class="required-tag">(知识库)</span></span>
<button class="add-btn" @click="addModel('rerank')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.rerank && llmConfig.rerank.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.rerank" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('rerank', index)"
@toggle="toggleRow('rerank', index, model)"
@update="(m) => updateModel('rerank', index, m)"
@delete="removeModel('rerank', index)"
@test="(m) => testModel('rerank', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 rerank 模型配置</div>
</div> </div>
</div> </div>
<button
class="save-btn full-width"
@click="saveLLM"
:disabled="saving || !isLlmDirty"
>
<div v-if="saving" class="btn-loader"></div>
<Save v-else :size="14" />
<span>{{ saving ? 'SAVING...' : 'SAVE LLM CONFIG' }}</span>
</button>
<!-- Scheduler Section --> <!-- Scheduler Section -->
<div class="settings-card"> <div class="settings-card">
<div class="card-header"> <div class="card-header">
@@ -857,6 +882,54 @@ onMounted(loadSettings)
border-top: 1px solid var(--border-dim); border-top: 1px solid var(--border-dim);
} }
/* LLM Type Section */
.llm-type-section {
margin-bottom: 20px;
}
.llm-type-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.llm-type-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--accent-cyan);
}
.optional-tag {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.required-tag {
font-size: 9px;
color: var(--accent-red);
letter-spacing: 0.1em;
}
/* Warning Bar */
.warning-bar {
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
color: var(--accent-red);
font-family: var(--font-mono);
font-size: 11px;
margin-bottom: 16px;
}
/* Model Table */
.model-table {
/* 表格容器 */
}
/* Empty State */ /* Empty State */
.empty-state { .empty-state {
display: flex; display: flex;