docs: update LLM config implementation plan

Fix delete constraint for embedding/rerank, add max 1 constraint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 11:25:32 +08:00
parent fcd13d8d9f
commit 79f25a3a74

View File

@@ -0,0 +1,764 @@
# LLM Config Table UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将 Settings 页面的 LLM 模型配置从卡片列表改为表格行内编辑形式
**Architecture:** 使用 Vue 3 Composition API在 SettingsView.vue 内实现 4 个 LLM 类型区的表格组件,每种类型独立成区,支持行内展开编辑、测试连接、保存操作。
**Tech Stack:** Vue 3, TypeScript, Lucide Icons
---
## File Structure
```
frontend/src/
├── views/
│ └── SettingsView.vue # 重构:表格行内编辑 UI所有逻辑内聚在此文件
└── components/settings/
└── LLMTableRow.vue # 表格行组件(抽取以保持 SettingsView.vue 简洁)
backend/app/
├── routers/settings.py # 确认测试 API 存在
└── services/settings_service.py # 确认无需修改
```
---
## Task 1: 验证后端测试 API
**Files:**
- Modify: `backend/app/routers/settings.py`
- Modify: `backend/app/services/settings_service.py`
- [ ] **Step 1: 确认 `/api/settings/llm/test` 端点存在**
检查 `backend/app/routers/settings.py` 中是否有 `POST /api/settings/llm/test` 路由。
- [ ] **Step 2: 确认 `test_llm_connection` 函数存在**
检查 `backend/app/services/settings_service.py` 中是否有 `test_llm_connection` 函数。
- [ ] **Step 3: 提交**
```bash
git log --oneline -1
# 如果确认后端无需修改:
echo "Backend API already supports the new UI"
# 如果发现问题需要修复:
# git add backend/app/routers/settings.py
# git commit -m "fix(settings): ensure test LLM API exists"
```
---
## Task 2: 创建 LLMTableRow 组件
**Files:**
- Create: `frontend/src/components/settings/LLMTableRow.vue`
- Modify: `frontend/src/views/SettingsView.vue`
- [ ] **Step 1: 创建 LLMTableRow.vue 组件**
```vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Eye, EyeOff, Play, ChevronDown, ChevronRight, Trash2 } from 'lucide-vue-next'
import type { LLMModelConfig } from '@/api/settings'
const props = defineProps<{
model: LLMModelConfig
isExpanded: boolean
isNew?: boolean
}>()
const emit = defineEmits<{
(e: 'toggle'): void
(e: 'update', data: LLMModelConfig): void
(e: 'delete'): void
(e: 'test', data: LLMModelConfig): void
}>()
const showApiKey = ref(false)
const status = computed(() => {
if (!props.model.api_key || !props.model.model) return 'empty'
if (props.model.enabled) return 'available'
return 'unavailable'
})
const statusConfig = computed(() => ({
available: { icon: '●', color: '#10b981', label: '可用' },
unavailable: { icon: '○', color: '#6b7280', label: '不可用' },
empty: { icon: '⚠', color: '#ef4444', label: '未配置' }
}[status.value]))
function onProviderChange() {
const defaults: Record<string, string> = {
ollama: 'http://localhost:11434',
openai: 'https://api.openai.com/v1',
claude: 'https://api.anthropic.com',
deepseek: 'https://api.deepseek.com/v1'
}
if (!props.model.base_url || Object.values(defaults).includes(props.model.base_url)) {
emit('update', { ...props.model, base_url: defaults[props.model.provider] || '' })
}
}
</script>
<template>
<div class="table-row" :class="{ expanded: isExpanded, 'is-new': isNew }">
<!-- 表格行可点击展开 -->
<div class="row-summary" @click="emit('toggle')">
<div class="cell cell-toggle">
<ChevronDown v-if="isExpanded" :size="14" />
<ChevronRight v-else :size="14" />
</div>
<div class="cell cell-name">{{ model.name || '未命名' }}</div>
<div class="cell cell-provider">{{ model.provider }}</div>
<div class="cell cell-model">{{ model.model || '-' }}</div>
<div class="cell cell-status" :style="{ color: statusConfig.color }">
{{ statusConfig.icon }} {{ statusConfig.label }}
</div>
<div class="cell cell-actions" @click.stop>
<button class="icon-btn danger" @click="emit('delete')" title="删除">
<Trash2 :size="12" />
</button>
</div>
</div>
<!-- 展开的详情面板 -->
<div v-if="isExpanded" class="expand-panel">
<div class="form-row">
<div class="form-group">
<label>// PROVIDER</label>
<select v-model="model.provider" @change="onProviderChange">
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="ollama">Ollama</option>
<option value="deepseek">DeepSeek</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="form-group">
<label>// MODEL</label>
<input v-model="model.model" type="text" placeholder="gpt-4o" />
</div>
</div>
<div class="form-group">
<label>// BASE URL</label>
<input v-model="model.base_url" type="text" />
</div>
<div class="form-group">
<label>// API KEY</label>
<div class="input-with-toggle">
<input
v-model="model.api_key"
:type="showApiKey ? 'text' : 'password'"
placeholder="sk-..."
/>
<button @click="showApiKey = !showApiKey">
<Eye v-if="!showApiKey" :size="14" />
<EyeOff v-else :size="14" />
</button>
</div>
</div>
<div class="panel-actions">
<button class="test-btn" @click="emit('test', model)">
<Play :size="12" /> 测试连接
</button>
<button
class="save-btn"
:disabled="status !== 'available'"
@click="emit('update', model)"
>
保存
</button>
<button class="cancel-btn" @click="emit('toggle')">取消</button>
</div>
</div>
</div>
</template>
<style scoped>
.table-row {
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
margin-bottom: 8px;
background: var(--bg-card);
}
.row-summary {
display: flex;
align-items: center;
padding: 10px 14px;
cursor: pointer;
}
.row-summary:hover {
background: rgba(0,245,212,0.05);
}
.cell {
font-family: var(--font-mono);
font-size: 11px;
}
.cell-toggle { width: 30px; }
.cell-name { flex: 1; min-width: 120px; }
.cell-provider { width: 80px; }
.cell-model { width: 120px; }
.cell-status { width: 80px; }
.cell-actions { width: 40px; text-align: right; }
.expand-panel {
padding: 14px;
border-top: 1px solid var(--border-dim);
background: var(--bg-void);
}
.form-row {
display: flex;
gap: 14px;
}
.form-row .form-group {
flex: 1;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 6px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px 10px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
}
.input-with-toggle {
display: flex;
gap: 8px;
}
.input-with-toggle input {
flex: 1;
}
.input-with-toggle button {
width: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
}
.panel-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 14px;
}
.test-btn, .save-btn, .cancel-btn {
padding: 6px 14px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.test-btn {
background: transparent;
border: 1px solid rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
.save-btn {
background: rgba(0,245,212,0.1);
border: 1px solid rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cancel-btn {
background: transparent;
border: 1px solid var(--border-mid);
color: var(--text-dim);
}
.icon-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
}
.icon-btn.danger:hover {
border-color: var(--accent-red);
color: var(--accent-red);
}
</style>
```
- [ ] **Step 2: 在 SettingsView.vue 中引入 LLMTableRow**
```typescript
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
```
- [ ] **Step 3: 提交**
```bash
git add frontend/src/components/settings/LLMTableRow.vue
git commit -m "feat(settings): create LLMTableRow component"
```
---
## Task 3: 重构 SettingsView.vue 主体结构
**Files:**
- Modify: `frontend/src/views/SettingsView.vue`
- [ ] **Step 1: 清理原有 LLM 配置相关代码**
删除原有的 `llmConfig` 卡片列表 UItemplate 中的 `.model-list``.model-item` 等部分),保留 profile 和 scheduler 配置部分。
- [ ] **Step 2: 添加 LLM 配置状态管理**
```typescript
// LLM 配置
const llmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: []
})
// 原始配置(用于比较变更)
const originalLlmConfig = ref<LLMConfig>({ chat: [], vlm: [], embedding: [], rerank: [] })
// 展开的行
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
})
// 行标识
function getRowKey(type: string, index: number): string {
return `${type}-${index}`
}
// 切换行展开
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
}
```
- [ ] **Step 3: 实现添加模型**
```typescript
function addModel(type: string) {
if (!llmConfig.value[type as keyof LLMConfig]) {
llmConfig.value[type as keyof LLMConfig] = []
}
// 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: LLMModelConfig = {
name: `${type.toUpperCase()}-${Date.now()}`,
provider: 'openai',
model: type === 'chat' ? 'gpt-4o' : type === 'vlm' ? 'gpt-4o' : type === 'embedding' ? 'text-embedding-3-small' : 'bge-reranker-v2',
base_url: 'https://api.openai.com/v1',
api_key: '',
enabled: false
}
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)) }
}
```
- [ ] **Step 4: 实现删除模型**
```typescript
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)
expandedRow.value = null
editingSnapshot.value = null
}
```
- [ ] **Step 5: 实现更新模型**
```typescript
function updateModel(type: string, index: number, model: LLMModelConfig) {
llmConfig.value[type as keyof LLMConfig]![index] = model
}
```
- [ ] **Step 6: 实现测试连接**
```typescript
async function testModel(type: string, index: number, model: LLMModelConfig) {
try {
const res = await settingsApi.testLLM({ type: type as any, ...model })
if (res.data.success) {
// 测试通过,标记为可用
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
showToast('连接成功')
} else {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast(`连接失败: ${res.data.error}`, 'error')
}
} catch (e) {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast('测试连接失败', 'error')
}
}
```
- [ ] **Step 7: 实现保存模型**
```typescript
async function saveModel(type: string, index: number) {
const key = getRowKey(type, index)
savingModel.value = key
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
expandedRow.value = null
editingSnapshot.value = null
showToast('保存成功')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
showToast(msg, 'error')
} finally {
savingModel.value = null
}
}
```
- [ ] **Step 8: 编写 template 的 LLM Config Section**
在 Profile section 之后Scheduler section 之前添加:
```html
<!-- LLM Config Section -->
<div class="settings-card">
<div class="card-header">
<span class="card-title">// LLM CONFIGURATION</span>
</div>
<!-- 必填警告 -->
<div v-if="showRequiredWarning" class="warning-bar">
⚠ chat / embedding / rerank 为知识库必填,请确保已配置
</div>
<!-- Chat Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">CHAT</span>
<button class="add-btn" @click="addModel('chat')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.chat && llmConfig.chat.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.chat" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('chat', index)"
@toggle="toggleRow('chat', index, model)"
@update="(m) => updateModel('chat', index, m)"
@delete="removeModel('chat', index)"
@test="(m) => testModel('chat', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 chat 模型配置</div>
</div>
<!-- VLM Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">VLM <span class="optional-tag">(可选)</span></span>
<button class="add-btn" @click="addModel('vlm')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.vlm && llmConfig.vlm.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.vlm" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('vlm', index)"
@toggle="toggleRow('vlm', index, model)"
@update="(m) => updateModel('vlm', index, m)"
@delete="removeModel('vlm', index)"
@test="(m) => testModel('vlm', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 vlm 模型配置</div>
</div>
<!-- Embedding Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">EMBEDDING <span class="required-tag">(知识库)</span></span>
<button class="add-btn" @click="addModel('embedding')">
<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>
```
- [ ] **Step 9: 添加相关样式**
```css
/* 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 {
padding: 20px;
text-align: center;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
border: 1px dashed var(--border-dim);
border-radius: var(--radius-sm);
}
```
- [ ] **Step 10: 更新 loadSettings 函数**
确保 LLM 配置正确加载:
```typescript
async function loadSettings() {
loading.value = true
try {
const res = await settingsApi.get()
profile.value = {
email: res.data.profile.email,
full_name: res.data.profile.full_name || '',
created_at: res.data.profile.created_at
}
originalProfile.value = { ...profile.value }
// 加载 LLM 配置
if (res.data.llm_config) {
llmConfig.value = {
chat: res.data.llm_config.chat || [],
vlm: res.data.llm_config.vlm || [],
embedding: res.data.llm_config.embedding || [],
rerank: res.data.llm_config.rerank || []
}
} else {
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
}
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
if (res.data.scheduler_config && Object.keys(res.data.scheduler_config).length > 0) {
schedulerConfig.value = res.data.scheduler_config as SchedulerConfig
}
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
} catch (e) {
console.error('加载设置失败', e)
showToast('加载设置失败', 'error')
} finally {
loading.value = false
}
}
```
- [ ] **Step 11: 注册组件**
```typescript
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
// 在 components 中注册
components: { LLMTableRow }
```
- [ ] **Step 12: 提交**
```bash
git add frontend/src/views/SettingsView.vue
git commit -m "feat(settings): refactor LLM config to table inline-edit UI"
```
---
## Task 4: 测试和验证
**Files:**
- Test: `frontend/src/views/SettingsView.vue`
- [ ] **Step 1: 手动测试流程**
1. 打开 Settings 页面
2. 确认 chat/embedding/rerank 必填警告(如果为空)
3. 添加新模型,点击 [+] 按钮
4. 填写模型信息,点击"测试连接"
5. 测试通过后,"保存"按钮可用
6. 保存成功,刷新页面确认数据持久化
7. 点击"取消"验证表单数据恢复
- [ ] **Step 2: 提交**
```bash
git add -A
git commit -m "feat(settings): complete LLM config table UI implementation"
```