diff --git a/docs/superpowers/plans/2026-03-21-llm-config-table-implementation.md b/docs/superpowers/plans/2026-03-21-llm-config-table-implementation.md new file mode 100644 index 0000000..571ab1d --- /dev/null +++ b/docs/superpowers/plans/2026-03-21-llm-config-table-implementation.md @@ -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 + + + + + + + + + + + {{ model.name || '未命名' }} + {{ model.provider }} + {{ model.model || '-' }} + + {{ statusConfig.icon }} {{ statusConfig.label }} + + + + + + + + + + + + + // PROVIDER + + OpenAI + Claude + Ollama + DeepSeek + Custom + + + + // MODEL + + + + + // BASE URL + + + + // API KEY + + + + + + + + + + + 测试连接 + + + 保存 + + 取消 + + + + + + +``` + +- [ ] **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({ + chat: [], + vlm: [], + embedding: [], + rerank: [] +}) + +// 原始配置(用于比较变更) +const originalLlmConfig = ref({ chat: [], vlm: [], embedding: [], rerank: [] }) + +// 展开的行 +const expandedRow = ref(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 CONFIGURATION + + + + + ⚠ chat / embedding / rerank 为知识库必填,请确保已配置 + + + + + + CHAT + + 添加 + + + + + updateModel('chat', index, m)" + @delete="removeModel('chat', index)" + @test="(m) => testModel('chat', index, m)" + /> + + + 暂无 chat 模型配置 + + + + + + VLM (可选) + + 添加 + + + + + updateModel('vlm', index, m)" + @delete="removeModel('vlm', index)" + @test="(m) => testModel('vlm', index, m)" + /> + + + 暂无 vlm 模型配置 + + + + + + EMBEDDING (知识库) + + 添加 + + + + + updateModel('embedding', index, m)" + @delete="removeModel('embedding', index)" + @test="(m) => testModel('embedding', index, m)" + /> + + + 暂无 embedding 模型配置 + + + + + + RERANK (知识库) + + 添加 + + + + + updateModel('rerank', index, m)" + @delete="removeModel('rerank', index)" + @test="(m) => testModel('rerank', index, m)" + /> + + + 暂无 rerank 模型配置 + + +``` + +- [ ] **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" +```