Files
JARVIS/docs/superpowers/plans/2026-03-20-settings-register-plan.md

25 KiB
Raw Blame History

注册界面 + 设置界面 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: 实现用户注册界面和设置界面,支持多用户和用户级 LLM 配置

Architecture:

  • 后端:扩展 User 模型,新建 settings router/service前端认证依赖现有 auth 机制
  • 前端LoginView 添加注册 Tab新建 SettingsView 页面,复用现有 sci-fi 风格
  • 数据User 表增加 JSON 字段存储 llm_config 和 scheduler_config

Tech Stack: FastAPI + SQLAlchemy + Vue 3 + axios + Pinia


文件总览

backend/
  app/
    models/
      user.py              # 修改:添加 llm_config, scheduler_config 字段
    schemas/
      settings.py          # 新建Settings Pydantic schemas
    routers/
      settings.py          # 新建settings API router
    services/
      settings_service.py  # 新建:设置逻辑服务

frontend/
  src/
    api/
      settings.ts          # 新建settings API 客户端
    views/
      LoginView.vue        # 修改:添加注册 Tab
      SettingsView.vue     # 新建:设置页面
    router/
      index.ts            # 修改:添加 /settings 路由
    components/
      SidebarNav.vue       # 修改:添加设置菜单

Task 1: 后端 - User 模型扩展

Files:

  • Modify: backend/app/models/user.py

  • Step 1: 添加 JSON 字段到 User 模型

读取现有 User 模型,添加 llm_config 和 scheduler_config 字段:

# 在 User 模型类中添加
from sqlalchemy import JSON

llm_config = Column(JSON, nullable=True)  # 用户 LLM 配置
scheduler_config = Column(JSON, nullable=True)  # 定时任务配置
  • Step 2: 设置默认值

确保新用户创建时有默认配置(在 User 模型或 service 层处理)

  • Step 3: 提交
git add backend/app/models/user.py
git commit -m "feat(settings): add llm_config and scheduler_config fields to User model"

Task 2: 后端 - Settings Schema 定义

Files:

  • Create: backend/app/schemas/settings.py

  • Step 1: 创建 settings schemas

from pydantic import BaseModel, Field
from typing import Optional

# LLM Provider 类型
LLMProviderType = Literal["openai", "claude", "ollama", "deepseek", "custom"]
LLMType = Literal["chat", "vlm", "embedding", "rerank"]

# 单个模型配置
class LLMModelConfig(BaseModel):
    provider: LLMProviderType = "openai"
    model: str = ""
    base_url: str = ""
    api_key: str = ""

# LLM 配置输入
class LLMConfigIn(BaseModel):
    chat: Optional[LLMModelConfig] = None
    vlm: Optional[LLMModelConfig] = None
    embedding: Optional[LLMModelConfig] = None
    rerank: Optional[LLMModelConfig] = None

# 定时任务配置
class SchedulerConfigIn(BaseModel):
    daily_plan_time: Optional[str] = "08:00"
    forum_scan_interval_minutes: Optional[int] = 30
    todo_ai_generate_time: Optional[str] = "08:00"
    enabled: Optional[bool] = True

# 用户资料更新
class ProfileUpdateIn(BaseModel):
    full_name: Optional[str] = Field(None, min_length=2, max_length=50)
    password: Optional[str] = Field(None, min_length=8)
    current_password: Optional[str] = None  # 修改密码时需要验证

# 完整设置输出
class SettingsOut(BaseModel):
    profile: "UserOut"  # 引用 auth.py 中的 UserOut
    llm_config: Optional[dict] = None
    scheduler_config: Optional[dict] = None

    model_config = {"from_attributes": True}

# 测试 LLM 连接请求
class LLMTestIn(BaseModel):
    type: LLMType
    provider: LLMProviderType
    model: str
    base_url: str
    api_key: str
  • Step 2: 提交
git add backend/app/schemas/settings.py
git commit -m "feat(settings): add Pydantic schemas for settings API"

Task 3: 后端 - Settings Service

Files:

  • Create: backend/app/services/settings_service.py

  • Step 1: 创建设置服务

主要功能:

  1. get_user_settings(user_id) - 获取用户完整设置
  2. update_user_profile(user_id, data) - 更新用户资料
  3. update_llm_config(user_id, config) - 更新 LLM 配置
  4. update_scheduler_config(user_id, config) - 更新定时任务配置
  5. test_llm_connection(data) - 测试 LLM 连接
import logging
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.user import User
from app.services.auth_service import verify_password, get_password_hash
from app.services.llm_service import get_llm
from langchain_core.messages import HumanMessage, SystemMessage

logger = logging.getLogger(__name__)

async def get_user_settings(user_id: str, db: AsyncSession) -> dict:
    """获取用户完整设置"""
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        return None
    return {
        "profile": user,
        "llm_config": user.llm_config or {},
        "scheduler_config": user.scheduler_config or {}
    }

async def update_user_profile(
    user_id: str,
    db: AsyncSession,
    full_name: Optional[str] = None,
    password: Optional[str] = None,
    current_password: Optional[str] = None
) -> User:
    """更新用户资料"""
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        raise ValueError("用户不存在")

    if password:
        if not current_password or not verify_password(current_password, user.hashed_password):
            raise ValueError("当前密码错误")
        user.hashed_password = get_password_hash(password)

    if full_name:
        user.full_name = full_name

    await db.commit()
    await db.refresh(user)
    return user

async def update_llm_config(user_id: str, config: dict, db: AsyncSession) -> dict:
    """更新 LLM 配置"""
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        raise ValueError("用户不存在")

    current = user.llm_config or {}
    # 合并配置
    for key, value in config.items():
        if value is not None:
            current[key] = value
    user.llm_config = current
    await db.commit()
    return current

async def update_scheduler_config(user_id: str, config: dict, db: AsyncSession) -> dict:
    """更新定时任务配置"""
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        raise ValueError("用户不存在")

    current = user.scheduler_config or {}
    for key, value in config.items():
        if value is not None:
            current[key] = value
    user.scheduler_config = current
    await db.commit()
    return current

async def test_llm_connection(
    provider: str,
    model: str,
    base_url: str,
    api_key: str
) -> dict:
    """测试 LLM 连接"""
    try:
        # 根据不同 provider 创建临时 LLM 实例并测试
        if provider == "openai":
            from langchain_openai import ChatOpenAI
            llm = ChatOpenAI(
                api_key=api_key,
                model=model,
                base_url=base_url or None,
                timeout=30
            )
        elif provider == "claude":
            from langchain_anthropic import ChatAnthropic
            llm = ChatAnthropic(
                api_key=api_key,
                model=model,
                timeout=30
            )
        elif provider == "ollama":
            from langchain_ollama import ChatOllama
            llm = ChatOllama(
                base_url=base_url or "http://localhost:11434",
                model=model,
                timeout=30
            )
        elif provider == "deepseek":
            from langchain_openai import ChatOpenAI
            llm = ChatOpenAI(
                api_key=api_key,
                model=model,
                base_url=base_url or "https://api.deepseek.com/v1",
                timeout=30
            )
        else:
            return {"success": False, "error": f"不支持的 provider: {provider}"}

        # 简单测试调用
        response = await llm.ainvoke([HumanMessage(content="Hi")])
        return {"success": True, "message": f"连接成功,模型响应: {response.content[:50]}..."}
    except Exception as e:
        return {"success": False, "error": str(e)}
  • Step 2: 提交
git add backend/app/services/settings_service.py
git commit -m "feat(settings): add settings service with LLM config management"

Task 4: 后端 - Settings Router

Files:

  • Create: backend/app/routers/settings.py

  • Step 1: 创建 settings router

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.settings import (
    SettingsOut, ProfileUpdateIn, LLMConfigIn, SchedulerConfigIn, LLMTestIn
)
from app.services.settings_service import (
    get_user_settings, update_user_profile, update_llm_config,
    update_scheduler_config, test_llm_connection
)

router = APIRouter(prefix="/api/settings", tags=["设置"])

@router.get("", response_model=SettingsOut)
async def get_settings(
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    settings = await get_user_settings(current_user.id, db)
    if not settings:
        raise HTTPException(status_code=404, detail="用户不存在")
    return settings

@router.put("/profile")
async def update_profile(
    data: ProfileUpdateIn,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    try:
        user = await update_user_profile(
            current_user.id, db,
            full_name=data.full_name,
            password=data.password,
            current_password=data.current_password
        )
        return user
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

@router.put("/llm")
async def update_llm(
    data: LLMConfigIn,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    try:
        config = await update_llm_config(current_user.id, data.model_dump(exclude_none=True), db)
        return {"llm_config": config}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

@router.post("/llm/test")
async def test_llm(
    data: LLMTestIn,
    current_user: User = Depends(get_current_user),
):
    result = await test_llm_connection(
        provider=data.provider,
        model=data.model,
        base_url=data.base_url,
        api_key=data.api_key
    )
    return result

@router.put("/scheduler")
async def update_scheduler(
    data: SchedulerConfigIn,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    try:
        config = await update_scheduler_config(
            current_user.id,
            data.model_dump(exclude_none=True),
            db
        )
        return {"scheduler_config": config}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
  • Step 2: 注册 router 到 main.py 和 routers/init.py

backend/app/routers/__init__.py 添加:

from app.routers.settings import router as settings_router

backend/app/main.py 添加:

from app.routers.settings import router as settings_router
# ...
app.include_router(settings_router)
  • Step 3: 提交
git add backend/app/routers/settings.py backend/app/routers/__init__.py backend/app/main.py
git commit -m "feat(settings): add settings router with profile, LLM and scheduler endpoints"

Task 5: 前端 - Settings API 客户端

Files:

  • Create: frontend/src/api/settings.ts

  • Step 1: 创建 settings API 客户端

import api from './index'

export type LLMProvider = 'openai' | 'claude' | 'ollama' | 'deepseek' | 'custom'
export type LLMType = 'chat' | 'vlm' | 'embedding' | 'rerank'

export interface LLMModelConfig {
  provider: LLMProvider
  model: string
  base_url: string
  api_key: string
}

export interface LLMConfig {
  chat?: LLMModelConfig
  vlm?: LLMModelConfig
  embedding?: LLMModelConfig
  rerank?: LLMModelConfig
}

export interface SchedulerConfig {
  daily_plan_time?: string
  forum_scan_interval_minutes?: number
  todo_ai_generate_time?: string
  enabled?: boolean
}

export interface ProfileUpdate {
  full_name?: string
  password?: string
  current_password?: string
}

export interface SettingsResponse {
  profile: {
    id: string
    email: string
    full_name: string
    created_at: string
  }
  llm_config: LLMConfig
  scheduler_config: SchedulerConfig
}

export const settingsApi = {
  // 获取设置
  get() {
    return api.get<SettingsResponse>('/api/settings')
  },

  // 更新资料
  updateProfile(data: ProfileUpdate) {
    return api.put('/api/settings/profile', data)
  },

  // 更新 LLM 配置
  updateLLM(config: Partial<LLMConfig>) {
    return api.put('/api/settings/llm', config)
  },

  // 测试 LLM 连接
  testLLM(data: { type: LLMType } & LLMModelConfig) {
    return api.post('/api/settings/llm/test', data)
  },

  // 更新定时任务配置
  updateScheduler(config: Partial<SchedulerConfig>) {
    return api.put('/api/settings/scheduler', config)
  },
}
  • Step 2: 提交
git add frontend/src/api/settings.ts
git commit -m "feat(settings): add settings API client"

Task 6: 前端 - LoginView 注册功能

Files:

  • Modify: frontend/src/views/LoginView.vue

  • Step 1: 添加注册 Tab 和表单

在 script setup 中添加:

const isLogin = ref(true)
const registerEmail = ref('')
const registerPassword = ref('')
const registerConfirmPassword = ref('')
const registerName = ref('')
const isRegistering = ref(false)
const registerError = ref('')

// 密码强度计算
function getPasswordStrength(pwd: string): { level: 'weak' | 'medium' | 'strong', text: string } {
  if (pwd.length < 8) return { level: 'weak', text: '太短' }
  let score = 0
  if (pwd.length >= 8) score++
  if (pwd.length >= 12) score++
  if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
  if (/\d/.test(pwd)) score++
  if (/[^a-zA-Z0-9]/.test(pwd)) score++
  if (score <= 2) return { level: 'weak', text: '弱' }
  if (score <= 3) return { level: 'medium', text: '中' }
  return { level: 'strong', text: '强' }
}

const passwordStrength = computed(() => getPasswordStrength(registerPassword.value))

async function handleRegister() {
  if (registerPassword.value !== registerConfirmPassword.value) {
    registerError.value = '两次密码输入不一致'
    return
  }
  if (registerPassword.value.length < 8) {
    registerError.value = '密码至少需要8个字符'
    return
  }

  try {
    registerError.value = ''
    isRegistering.value = true
    await authApi.register({
      email: registerEmail.value,
      password: registerPassword.value,
      full_name: registerName.value
    })
    // 注册成功后自动登录
    await auth.login(registerEmail.value, registerPassword.value)
    router.push('/chat')
  } catch (e: unknown) {
    registerError.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '注册失败'
  } finally {
    isRegistering.value = false
  }
}

在 template 中添加注册表单(与登录表单并列,用 v-if 切换)

  • Step 2: 提交
git add frontend/src/views/LoginView.vue
git commit -m "feat(auth): add registration form to LoginView"

Task 7: 前端 - SettingsView 页面

Files:

  • Create: frontend/src/views/SettingsView.vue

  • Step 1: 创建设置页面

页面结构:

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig } from '@/api/settings'
import { Save, RotateCcw, Eye, EyeOff, Play } from 'lucide-vue-next'

// 状态
const loading = ref(false)
const saving = ref(false)
const showApiKey = ref<Record<string, boolean>>({})

// 用户资料
const profile = ref({ email: '', full_name: '' })
const newPassword = ref('')

// LLM 配置
const llmConfig = ref<LLMConfig>({
  chat: { provider: 'openai', model: 'gpt-4o', base_url: '', api_key: '' },
  vlm: { provider: 'openai', model: 'gpt-4o', base_url: '', api_key: '' },
  embedding: { provider: 'openai', model: 'text-embedding-3-small', base_url: '', api_key: '' },
  rerank: { provider: 'openai', model: 'bge-reranker-v2', base_url: '', api_key: '' },
})

// 定时任务配置
const schedulerConfig = ref<SchedulerConfig>({
  daily_plan_time: '08:00',
  forum_scan_interval_minutes: 30,
  todo_ai_generate_time: '08:00',
  enabled: true,
})

// 加载设置
async function loadSettings() {
  loading.value = true
  try {
    const res = await settingsApi.get()
    profile.value = { ...res.data.profile }
    llmConfig.value = { ...res.data.llm_config }
    schedulerConfig.value = { ...res.data.scheduler_config }
  } catch (e) {
    console.error('加载设置失败', e)
  } finally {
    loading.value = false
  }
}

// 保存资料
async function saveProfile() {
  saving.value = true
  try {
    await settingsApi.updateProfile({ full_name: profile.value.full_name })
    if (newPassword.value) {
      const currentPwd = prompt('请输入当前密码以确认修改:')
      if (!currentPwd) {
        alert('密码修改已取消')
        return
      }
      await settingsApi.updateProfile({
        password: newPassword.value,
        current_password: currentPwd
      })
      newPassword.value = ''
      alert('密码修改成功')
    }
  } catch (e: unknown) {
    alert((e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败')
  } finally {
    saving.value = false
  }
}

// 保存 LLM 配置
async function saveLLM() {
  saving.value = true
  try {
    await settingsApi.updateLLM(llmConfig.value)
  } finally {
    saving.value = false
  }
}

// 测试 LLM 连接
async function testLLM(type: string, config: LLMModelConfig) {
  try {
    const res = await settingsApi.testLLM({ type, ...config })
    alert(res.data.success ? `成功: ${res.data.message}` : `失败: ${res.data.error}`)
  } catch (e) {
    alert('测试连接失败')
  }
}

// 保存定时任务配置
async function saveScheduler() {
  saving.value = true
  try {
    await settingsApi.updateScheduler(schedulerConfig.value)
  } finally {
    saving.value = false
  }
}

// Provider 默认值
function getDefaultBaseUrl(provider: string) {
  switch (provider) {
    case 'ollama': return 'http://localhost:11434'
    case 'openai': return 'https://api.openai.com/v1'
    case 'claude': return 'https://api.anthropic.com'
    default: return ''
  }
}

onMounted(loadSettings)
</script>

<template>
  <div class="settings-view">
    <!-- Header -->
    <div class="view-header">
      <span class="header-title">SETTINGS</span>
    </div>

    <!-- Content -->
    <div class="settings-content">
      <!-- Profile Section -->
      <div class="settings-card">
        <div class="card-title">PROFILE</div>
        <div class="form-group">
          <label>Email</label>
          <input v-model="profile.email" type="email" disabled />
        </div>
        <div class="form-group">
          <label>Name</label>
          <input v-model="profile.full_name" type="text" />
        </div>
        <div class="form-group">
          <label>New Password</label>
          <input v-model="newPassword" type="password" placeholder="Leave empty to keep current" />
        </div>
        <button class="save-btn" @click="saveProfile" :disabled="saving">
          {{ saving ? 'Saving...' : 'Save Profile' }}
        </button>
      </div>

      <!-- LLM Config Section -->
      <div v-for="(config, type) in llmConfig" :key="type" class="settings-card">
        <div class="card-title">{{ type.toUpperCase() }}</div>
        <div class="form-row">
          <div class="form-group">
            <label>Provider</label>
            <select v-model="config.provider">
              <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="config.model" type="text" />
          </div>
        </div>
        <div class="form-group">
          <label>Base URL</label>
          <input v-model="config.base_url" type="text" :placeholder="getDefaultBaseUrl(config.provider)" />
        </div>
        <div class="form-group">
          <label>API Key</label>
          <div class="input-with-toggle">
            <input v-model="config.api_key" :type="showApiKey[type] ? 'text' : 'password'" />
            <button @click="showApiKey[type] = !showApiKey[type]" class="toggle-btn">
              <Eye v-if="!showApiKey[type]" :size="14" />
              <EyeOff v-else :size="14" />
            </button>
          </div>
        </div>
        <button class="test-btn" @click="testLLM(type, config)">
          <Play :size="12" /> Test
        </button>
      </div>

      <!-- Scheduler Section -->
      <div class="settings-card">
        <div class="card-title">SCHEDULER</div>
        <div class="form-row">
          <div class="form-group">
            <label>Daily Plan Time</label>
            <input v-model="schedulerConfig.daily_plan_time" type="time" />
          </div>
          <div class="form-group">
            <label>Todo AI Generate Time</label>
            <input v-model="schedulerConfig.todo_ai_generate_time" type="time" />
          </div>
        </div>
        <div class="form-group">
          <label>Forum Scan Interval (minutes)</label>
          <input v-model.number="schedulerConfig.forum_scan_interval_minutes" type="number" min="5" max="1440" />
        </div>
        <div class="form-group toggle-group">
          <label>Scheduler Enabled</label>
          <button
            class="toggle-btn"
            :class="{ active: schedulerConfig.enabled }"
            @click="schedulerConfig.enabled = !schedulerConfig.enabled"
          >
            <span class="toggle-knob" />
          </button>
        </div>
        <button class="save-btn" @click="saveScheduler" :disabled="saving">
          {{ saving ? 'Saving...' : 'Save Scheduler' }}
        </button>
      </div>
    </div>
  </div>
</template>

样式部分复用 AgentView 的 sci-fi 风格,保持一致。

  • Step 2: 提交
git add frontend/src/views/SettingsView.vue
git commit -m "feat(settings): add SettingsView page with profile, LLM and scheduler config"

Task 8: 前端 - 路由和侧边栏

Files:

  • Modify: frontend/src/router/index.ts

  • Modify: frontend/src/components/SidebarNav.vue

  • Step 1: 添加 /settings 路由

在 children 数组中添加:

{
  path: 'settings',
  name: 'settings',
  component: () => import('@/views/SettingsView.vue'),
}
  • Step 2: 添加设置菜单项

在 navItems 中添加:

{ name: '设置', path: '/settings', icon: Settings },

导入 Settings 图标:

import { Settings } from 'lucide-vue-next'
  • Step 3: 提交
git add frontend/src/router/index.ts frontend/src/components/SidebarNav.vue
git commit -m "feat(settings): add /settings route and sidebar menu"

Task 9: 数据库迁移

  • Step 1: 创建迁移 SQL

由于使用 SQLAlchemy 的 init_db() 会在启动时自动创建表,但现有数据库不会自动添加新字段。需要:

  1. 直接在数据库上执行 ALTER TABLE
ALTER TABLE users ADD COLUMN llm_config TEXT;
ALTER TABLE users ADD COLUMN scheduler_config TEXT;
  1. 或通过 Python 脚本:
import asyncio
from app.database import engine

async def migrate():
    async with engine.begin() as conn:
        await conn.execute(text('ALTER TABLE users ADD COLUMN llm_config TEXT'))
        await conn.execute(text('ALTER TABLE users ADD COLUMN scheduler_config TEXT'))
    print('Migration complete')

asyncio.run(migrate())
  • Step 2: 提交迁移脚本
git add docs/superpowers/plans/2026-03-20-settings-migration.md
git commit -m "feat(settings): add database migration for user settings fields"

验证清单

完成所有 Task 后,验证以下内容:

  1. 注册功能 - 可以通过注册页面创建新账号
  2. 登录功能 - 新老用户都可以正常登录
  3. 设置页面 - 可以访问 /settings 页面
  4. 资料修改 - 用户名、密码可以修改
  5. LLM 配置 - 四种模型配置可以保存
  6. LLM 测试 - 测试连接功能正常
  7. 定时任务 - 时间间隔可以修改
  8. 配置持久化 - 重新登录后配置保留
  9. UI 风格 - 设置页面风格与其他页面一致

实现顺序建议

  1. Task 1 → 2 → 3 → 4后端核心
  2. Task 5前端 API
  3. Task 6LoginView 注册功能)
  4. Task 7SettingsView
  5. Task 8路由和侧边栏
  6. Task 9数据库迁移