Files
JARVIS/docs/superpowers/plans/2026-03-21-llm-config-table-implementation.md
DESKTOP-72TV0V4\caoxiaozhu 79f25a3a74 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>
2026-03-21 11:25:32 +08:00

21 KiB
Raw Blame History

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: 提交
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 组件

<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
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
  • Step 3: 提交
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 配置状态管理
// 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: 实现添加模型
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: 实现删除模型
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: 实现更新模型
function updateModel(type: string, index: number, model: LLMModelConfig) {
  llmConfig.value[type as keyof LLMConfig]![index] = model
}
  • Step 6: 实现测试连接
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: 实现保存模型
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 之前添加:

<!-- 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: 添加相关样式
/* 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 配置正确加载:

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: 注册组件
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
// 在 components 中注册
components: { LLMTableRow }
  • Step 12: 提交
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: 提交
git add -A
git commit -m "feat(settings): complete LLM config table UI implementation"