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:
@@ -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` 卡片列表 UI(template 中的 `.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"
|
||||
```
|
||||
Reference in New Issue
Block a user