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

904 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 注册界面 + 设置界面 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 字段:
```python
# 在 User 模型类中添加
from sqlalchemy import JSON
llm_config = Column(JSON, nullable=True) # 用户 LLM 配置
scheduler_config = Column(JSON, nullable=True) # 定时任务配置
```
- [ ] **Step 2: 设置默认值**
确保新用户创建时有默认配置(在 User 模型或 service 层处理)
- [ ] **Step 3: 提交**
```bash
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**
```python
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: 提交**
```bash
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 连接
```python
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: 提交**
```bash
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**
```python
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` 添加:
```python
from app.routers.settings import router as settings_router
```
`backend/app/main.py` 添加:
```python
from app.routers.settings import router as settings_router
# ...
app.include_router(settings_router)
```
- [ ] **Step 3: 提交**
```bash
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 客户端**
```typescript
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: 提交**
```bash
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 中添加:
```typescript
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: 提交**
```bash
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: 创建设置页面**
页面结构:
```vue
<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: 提交**
```bash
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 数组中添加:
```typescript
{
path: 'settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
}
```
- [ ] **Step 2: 添加设置菜单项**
在 navItems 中添加:
```typescript
{ name: '设置', path: '/settings', icon: Settings },
```
导入 Settings 图标:
```typescript
import { Settings } from 'lucide-vue-next'
```
- [ ] **Step 3: 提交**
```bash
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
```sql
ALTER TABLE users ADD COLUMN llm_config TEXT;
ALTER TABLE users ADD COLUMN scheduler_config TEXT;
```
2. 或通过 Python 脚本:
```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: 提交迁移脚本**
```bash
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数据库迁移