Add project documentation and specs

This commit is contained in:
2026-03-21 10:13:45 +08:00
parent e76f0828b9
commit 3a7f4174ab
20 changed files with 11179 additions and 0 deletions

View File

@@ -0,0 +1,711 @@
# Chat Enhancement 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:** 为沟通系统添加文件上传AI理解内容和表情包选择器功能
**Architecture:** 前端在 ChatView 输入区添加附件/Emoji按钮后端扩展 ChatRequest 支持 file_idsAgentService 读取文件内容作为上下文
**Tech Stack:** Vue 3 + TypeScript + FastAPI + SQLAlchemy + ChromaDB
---
## File Structure
```
frontend/src/
├── views/
│ └── ChatView.vue # 修改 - 添加附件/Emoji按钮
├── components/
│ └── chat/
│ ├── EmojiPicker.vue # 新建 - Emoji选择器
│ └── FileMessage.vue # 新建 - 文件消息气泡
└── api/
├── conversation.ts # 修改 - chat支持file_ids
└── document.ts # 新增 - getDocumentContent
backend/app/
├── routers/
│ ├── conversation.py # 修改 - ChatRequest支持file_ids
│ └── document.py # 修改 - 新增content接口
├── services/
│ └── agent_service.py # 修改 - chat支持文件上下文
└── models/
└── conversation.py # 修改 - Message新增attachments字段
```
---
## Task 1: 创建 EmojiPicker 组件
**Files:**
- Create: `frontend/src/components/chat/EmojiPicker.vue`
- [ ] **Step 1: 创建 EmojiPicker.vue**
```vue
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
select: [emoji: string]
close: []
}>()
const categories = [
{ key: 'smile', name: '😀', label: '笑脸' },
{ key: 'gesture', name: '👍', label: '手势' },
{ key: 'object', name: '📦', label: '物品' },
{ key: 'symbol', name: '💬', label: '符号' },
]
const emojiData: Record<string, string[]> = {
smile: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌'],
gesture: ['👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🖖', '🤙', '💪', '🙏', '👏'],
object: ['📄', '📁', '🖼️', '📊', '📝', '💾', '📧', '🔗', '📌', '🔍', '💡', '⚡'],
symbol: ['✅', '❌', '⚠️', '🔥', '💯', '🎯', '⭐', '✨', '💬', '🗨️', '❤️', '🧡'],
}
const activeCategory = ref('smile')
function selectEmoji(emoji: string) {
emit('select', emoji)
}
</script>
<template>
<div v-if="visible" class="emoji-picker">
<div class="emoji-tabs">
<button
v-for="cat in categories"
:key="cat.key"
:class="{ active: activeCategory === cat.key }"
@click="activeCategory = cat.key"
:title="cat.label"
>
{{ cat.name }}
</button>
</div>
<div class="emoji-grid">
<button
v-for="emoji in emojiData[activeCategory]"
:key="emoji"
class="emoji-btn"
@click="selectEmoji(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
</template>
<style scoped>
.emoji-picker {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
padding: 8px;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
z-index: 100;
min-width: 240px;
}
.emoji-tabs {
display: flex;
gap: 4px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-dim);
}
.emoji-tabs button {
flex: 1;
padding: 6px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: 16px;
cursor: pointer;
transition: all var(--transition-fast);
}
.emoji-tabs button:hover {
background: var(--accent-cyan-dim);
}
.emoji-tabs button.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 2px;
}
.emoji-btn {
padding: 6px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
font-size: 18px;
cursor: pointer;
transition: all var(--transition-fast);
}
.emoji-btn:hover {
background: var(--accent-cyan-dim);
transform: scale(1.2);
}
</style>
```
---
## Task 2: 创建 FileMessage 组件
**Files:**
- Create: `frontend/src/components/chat/FileMessage.vue`
- [ ] **Step 1: 创建 FileMessage.vue**
```vue
<script setup lang="ts">
import { computed } from 'vue'
import { FileText, Image, File } from 'lucide-vue-next'
const props = defineProps<{
filename: string
fileType: string
fileSize: number
}>()
const icon = computed(() => {
if (props.fileType.startsWith('image/')) return Image
if (props.fileType.includes('pdf') || props.fileType.includes('document')) return FileText
return File
})
const fileSizeDisplay = computed(() => {
const size = props.fileSize
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
return (size / (1024 * 1024)).toFixed(1) + ' MB'
})
const ext = computed(() => {
const parts = props.filename.split('.')
return parts.length > 1 ? parts.pop()?.toUpperCase() : ''
})
</script>
<template>
<div class="file-message">
<div class="file-icon">
<component :is="icon" :size="24" />
<span v-if="ext" class="file-ext">{{ ext }}</span>
</div>
<div class="file-info">
<span class="file-name">{{ filename }}</span>
<span class="file-size">{{ fileSizeDisplay }}</span>
</div>
</div>
</template>
<style scoped>
.file-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--accent-cyan-dim);
border: 1px solid rgba(0, 245, 212, 0.2);
border-radius: var(--radius-md);
min-width: 200px;
max-width: 300px;
}
.file-icon {
position: relative;
color: var(--accent-cyan);
flex-shrink: 0;
}
.file-ext {
position: absolute;
bottom: -2px;
right: -4px;
font-size: 7px;
font-family: var(--font-mono);
font-weight: 700;
color: var(--bg-void);
background: var(--accent-cyan);
padding: 1px 2px;
border-radius: 2px;
}
.file-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.file-name {
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-dim);
}
</style>
```
---
## Task 3: 修改前端 API - conversation.ts
**Files:**
- Modify: `frontend/src/api/conversation.ts`
- [ ] **Step 1: 修改 conversation.ts**
```typescript
// 在 chat 方法中添加 file_ids 参数
chat(message: string, conversationId?: string, fileIds: string[] = []) {
return api.post('/api/conversations/chat', {
message,
conversation_id: conversationId,
file_ids: fileIds,
})
}
```
---
## Task 4: 修改前端 API - document.ts
**Files:**
- Modify: `frontend/src/api/document.ts`
- [ ] **Step 1: 添加 getContent 方法**
```typescript
// 新增方法
getContent(id: string) {
return api.get<string>(`/api/documents/${id}/content`)
}
```
---
## Task 5: 修改 ChatView.vue - 添加按钮和状态
**Files:**
- Modify: `frontend/src/views/ChatView.vue`
- [ ] **Step 1: 在 script setup 中添加以下内容**
在 import 后添加:
```typescript
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
import FileMessage from '@/components/chat/FileMessage.vue'
import { Paperclip, Smile, Download } from 'lucide-vue-next'
// 新增状态
const fileInputRef = ref<HTMLInputElement>()
const showEmojiPicker = ref(false)
const selectedFiles = ref<{ id: string; name: string; type: string; size: number }[]>([])
const uploadingFiles = ref<{ name: string; progress: number }[]>([])
```
- [ ] **Step 2: 添加文件上传方法**
```typescript
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files?.length) return
for (const file of input.files) {
// 校验大小
if (file.size > 10 * 1024 * 1024) {
alert(`文件 ${file.name} 超过10MB限制`)
continue
}
// 显示上传中状态
uploadingFiles.value.push({ name: file.name, progress: 0 })
try {
const response = await documentApi.upload(file)
selectedFiles.value.push({
id: response.data.id,
name: file.name,
type: file.type,
size: file.size,
})
} catch (e) {
console.error('上传失败:', e)
alert(`文件 ${file.name} 上传失败`)
} finally {
uploadingFiles.value = uploadingFiles.value.filter(f => f.name !== file.name)
}
}
// 清空 input
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function insertEmoji(emoji: string) {
inputMessage.value += emoji
showEmojiPicker.value = false
}
function openFilePicker() {
fileInputRef.value?.click()
}
```
- [ ] **Step 3: 在 sendMessage 中处理文件上传**
修改 sendMessage 函数,在发送消息时附带 file_ids
```typescript
async function sendMessage() {
if (!inputMessage.value.trim() || isSending.value) return
// 如果有文件,先上传
if (selectedFiles.value.length > 0) {
// file_ids 已经在 selectedFiles 中
}
isSending.value = true
isTyping.value = true
const text = inputMessage.value.trim()
const fileIds = selectedFiles.value.map(f => f.id)
// 添加用户消息(带文件)
store.addMessage({
id: `temp-${Date.now()}`,
role: 'user',
content: text,
created_at: new Date().toISOString(),
attachments: selectedFiles.value,
})
inputMessage.value = ''
selectedFiles.value = [] // 清空已选文件
// ... 后续发送逻辑,传入 fileIds
}
```
- [ ] **Step 4: 在 template 中添加附件和 Emoji 按钮**
在输入框的按钮区域添加:
```vue
<!-- 文件选择 input隐藏 -->
<input
ref="fileInputRef"
type="file"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
style="display: none"
@change="handleFileSelect"
/>
<!-- 附件按钮 -->
<button class="attach-btn" @click="openFilePicker" title="上传文件">
<Paperclip :size="15" />
</button>
<!-- Emoji 按钮 -->
<div class="emoji-wrapper">
<button
class="emoji-btn"
:class="{ active: showEmojiPicker }"
@click="showEmojiPicker = !showEmojiPicker"
title="表情包"
>
<Smile :size="15" />
</button>
<EmojiPicker
:visible="showEmojiPicker"
@select="insertEmoji"
@close="showEmojiPicker = false"
/>
</div>
```
- [ ] **Step 5: 添加样式**
在 style 部分添加:
```css
/* 文件消息样式 */
.file-msg-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
}
.file-msg-row .msg-avatar {
margin-top: 4px;
}
/* 附件按钮 */
.attach-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.attach-btn:hover {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
/* Emoji 按钮 */
.emoji-wrapper {
position: relative;
}
.emoji-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.emoji-btn:hover,
.emoji-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
```
---
## Task 6: 修改后端 - ChatRequest 支持 file_ids
**Files:**
- Modify: `backend/app/schemas/conversation.py`
- [ ] **Step 1: 修改 ChatRequest**
```python
class ChatRequest(BaseModel):
message: str
conversation_id: str | None = None
agent_id: str | None = None
file_ids: list[str] = [] # 新增
```
---
## Task 7: 修改后端 - Message 新增 attachments 字段
**Files:**
- Modify: `backend/app/models/conversation.py`
- [ ] **Step 1: 修改 Message 模型**
```python
class Message(BaseModel):
__tablename__ = "messages"
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False, index=True)
role = Column(String(20), nullable=False) # user, assistant, system
content = Column(Text, nullable=False)
model = Column(String(100), nullable=True)
tokens_used = Column(Integer, nullable=True)
attachments = Column(JSON, nullable=True) # 新增: [{file_id, filename, file_type, file_size}]
conversation = relationship("Conversation", back_populates="messages")
```
---
## Task 8: 修改后端 - 新增 document content 接口
**Files:**
- Modify: `backend/app/routers/document.py`
- [ ] **Step 1: 添加 content 接口**
```python
@router.get("/{document_id}/content")
async def get_document_content(
document_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取文档的文本内容用于AI理解"""
from app.services.document_service import DocumentService
doc_svc = DocumentService(db)
content = await doc_svc.get_document_content(current_user.id, document_id)
if content is None:
raise HTTPException(status_code=404, detail="文档不存在或无内容")
return {"content": content}
```
---
## Task 9: 修改后端 - DocumentService 新增 get_document_content
**Files:**
- Modify: `backend/app/services/document_service.py`
- [ ] **Step 1: 添加 get_document_content 方法**
```python
async def get_document_content(self, user_id: str, document_id: str) -> str | None:
"""获取文档的文本内容"""
import os
result = await self.db.execute(
select(Document).where(
Document.id == document_id,
Document.user_id == user_id,
)
)
doc = result.scalar_one_or_none()
if not doc:
return None
file_path = doc.file_path
if not os.path.exists(file_path):
return None
# 根据文件类型读取内容
ext = doc.filename.split('.')[-1].lower()
try:
if ext == 'txt':
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
elif ext == 'md':
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
elif ext == 'pdf':
# 简单文本提取(生产环境应使用专业库)
# 这里可以先用 pdfplumber 或 PyPDF2
return f"[PDF文档] {doc.filename}"
else:
return f"[文档] {doc.filename}"
except Exception:
return f"[文档] {doc.filename}"
```
---
## Task 10: 修改后端 - AgentService 支持文件上下文
**Files:**
- Modify: `backend/app/services/agent_service.py`
- [ ] **Step 1: 修改 chat_simple 方法支持 file_ids**
在 chat_simple 方法中:
```python
async def chat_simple(
self,
user_id: str,
message: str,
conversation_id: str | None = None,
file_ids: list[str] = None,
) -> tuple[str, str, str]:
# ... 现有逻辑 ...
# 如果有文件,读取内容作为上下文
file_context = ""
if file_ids:
from app.services.document_service import DocumentService
doc_svc = DocumentService(self.db)
for file_id in file_ids:
content = await doc_svc.get_document_content(user_id, file_id)
if content:
file_context += f"\n\n[用户上传文件内容]\n{content}\n[/文件内容]"
# 将文件上下文添加到消息
full_message = f"{message}\n{file_context}" if file_context else message
# 调用 LLM
response = await self.llm.chat(full_message, ...)
```
---
## Task 11: 验证和测试
- [ ] **Step 1: 前端 TypeScript 检查**
```bash
cd frontend && npx vue-tsc --noEmit
```
- [ ] **Step 2: 后端语法检查**
```bash
cd backend && python -m py_compile app/routers/conversation.py app/services/agent_service.py app/services/document_service.py
```
- [ ] **Step 3: 启动服务测试**
```bash
# 后端
cd backend && python -m uvicorn app.main:app --reload
# 前端
cd frontend && npm run dev
```
---
## 执行选项
**1. Subagent-Driven (推荐)** - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代
**2. Inline Execution** - 在当前会话中按批次执行任务
选择哪种方式?

View File

@@ -0,0 +1,44 @@
# Daily Todo 数据库迁移
## 自动迁移
SQLAlchemy 会在应用启动时通过 `init_db()` 自动创建所有表,包括 `daily_todos` 表。
## 手动迁移(如需)
如果需要手动创建表,执行以下 SQL
```sql
CREATE TABLE IF NOT EXISTS daily_todos (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
title VARCHAR(500) NOT NULL,
is_completed BOOLEAN NOT NULL DEFAULT 0,
source VARCHAR(20) NOT NULL DEFAULT 'manual',
source_detail VARCHAR(500),
source_ref_id VARCHAR(36),
todo_date VARCHAR(10) NOT NULL,
completed_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_date ON daily_todos(user_id, todo_date);
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_id ON daily_todos(user_id);
```
## 表说明
| 字段 | 类型 | 说明 |
|------|------|------|
| id | VARCHAR(36) | 主键UUID |
| user_id | VARCHAR(36) | 所属用户,索引 |
| title | VARCHAR(500) | 待办标题 |
| is_completed | BOOLEAN | 是否完成,默认 false |
| source | VARCHAR(20) | 来源ai_kanban / ai_chat / manual |
| source_detail | VARCHAR(500) | 来源说明文本 |
| source_ref_id | VARCHAR(36) | 来源原始ID看板TaskID或对话ID |
| todo_date | VARCHAR(10) | 所属日期 YYYY-MM-DD |
| completed_at | TIMESTAMP | 完成时间 |
| created_at | TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | 更新时间 |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,215 @@
# LangSmith 集成实现计划
> **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:** 为 Jarvis 后端集成 LangSmith 追踪,实现调用追踪、对话历史管理和评估支持。
**Architecture:** LangGraph 的 `compile()` 方法接受全局 Callbacks 参数,会自动将 Callback 传播到所有节点的 LLM 调用。只需在 Graph 编译时注入 `LangChainTracer`,即可覆盖 Master/Planner/Executor/Librarian/Analyst 所有 5 个节点和工具调用。
**Tech Stack:** langsmith, langchain-core, langgraph
---
## 文件变更总览
| 文件 | 职责 |
|---|---|
| `backend/pyproject.toml` | 添加 langsmith 依赖 |
| `backend/.env.example` | 新增 LangSmith 环境变量 |
| `backend/app/config.py` | 新增 3 个配置字段 |
| `backend/app/config_tracing.py` | 新建callback 工厂函数 |
| `backend/app/agents/graph.py` | 修改 `create_agent_graph()` 支持 callbacks合并全局 callbacks |
---
### Task 1: 添加 langsmith 依赖
**Files:**
- Modify: `backend/pyproject.toml`
- [ ] **Step 1: 添加 langsmith 依赖**
`dependencies` 数组中 `"langchain-ollama>=0.4.0",` 后添加:
```toml
"langchain-ollama>=0.4.0",
# 可观测性
"langsmith>=0.1.0",
```
- [ ] **Step 2: 安装依赖**
Run: `cd backend && uv sync`
---
### Task 2: 添加环境变量模板
**Files:**
- Modify: `backend/.env.example`
- [ ] **Step 1: 在文件末尾添加 LangSmith 配置节**
`# === 定时任务 ===` 节之前添加:
```env
# === LangSmith 可观测性 ===
# 启用 LangSmith 追踪(可选)
LANGSMITH_TRACING=false
LANGSMITH_API_KEY=your-langsmith-api-key
LANGSMITH_PROJECT=jarvis-agent
```
---
### Task 3: Config 层添加 LangSmith 配置
**Files:**
- Modify: `backend/app/config.py`
- Create: `backend/app/config_tracing.py` (callback 工厂函数)
- [ ] **Step 1: 在 Settings 类中添加 3 个配置字段**
`# === NAS 部署 ===` 节之前添加:
```python
# === LangSmith 可观测性 ===
LANGSMITH_TRACING: bool = False
LANGSMITH_API_KEY: str = ""
LANGSMITH_PROJECT: str = "jarvis-agent"
```
- [ ] **Step 2: 创建 callback 工厂函数**
创建新文件 `backend/app/config_tracing.py`
```python
"""
LangSmith Tracing 配置
提供 Callback 工厂函数,用于 LangGraph 追踪
"""
from langchain_core.callbacks import LangChainTracer
from app.config import settings
def get_langsmith_callbacks() -> list:
"""
根据配置返回 LangSmith Callback 列表
未启用时返回空列表
"""
if not settings.LANGSMITH_TRACING:
return []
if not settings.LANGSMITH_API_KEY:
return []
return [
LangChainTracer(
project_name=settings.LANGSMITH_PROJECT,
)
]
```
---
### Task 4: 修改 Graph 接受 Callbacks
**Files:**
- Modify: `backend/app/agents/graph.py`
- [ ] **Step 1: 修改 create_agent_graph() 签名**
将函数签名从:
```python
def create_agent_graph():
```
改为:
```python
def create_agent_graph(callbacks: list | None = None):
```
- [ ] **Step 2: 将 callbacks 传给 compile()**
将:
```python
return graph.compile()
```
改为:
```python
return graph.compile(callbacks=callbacks)
```
- [ ] **Step 3: 修改 get_agent_graph() 注入默认 callbacks**
将:
```python
def get_agent_graph():
global _agent_graph
if _agent_graph is None:
_agent_graph = create_agent_graph()
return _agent_graph
```
改为:
```python
from app.config_tracing import get_langsmith_callbacks
def get_agent_graph(callbacks: list | None = None):
"""
获取编译好的 Agent 图(单例缓存)。
Callbacks 在首次编译时固定注入,后续调用忽略 callbacks 参数。
如需变更 Callbacks如修改 LANGCHAIN_PROJECT需重启服务。
Args:
callbacks: 可选的额外 Callbacks会与全局 LangSmith Callbacks 合并
"""
global _agent_graph
if _agent_graph is None:
langsmith_callbacks = get_langsmith_callbacks()
all_callbacks = (callbacks or []) + langsmith_callbacks
_agent_graph = create_agent_graph(callbacks=all_callbacks or None)
return _agent_graph
```
---
### Task 5: 验证集成
- [ ] **Step 1: 确认依赖安装**
Run: `cd backend && uv sync`
- [ ] **Step 2: 启动服务验证无报错**
Run: `cd backend && uv run uvicorn app.main:app --reload --port 8000`
- [ ] **Step 3: 配置 .env 并测试**
`.env` 中添加:
```env
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=your-api-key
LANGSMITH_PROJECT=jarvis-agent
```
发起一次 Agent 对话,访问 https://smith.langchain.com 确认 trace 出现。
预期在 Dashboard 中看到:
- 5 个节点master/planner/executor/librarian/analyst的执行记录
- 每个节点的 LLM 输入/输出
- 工具调用记录
- Token 消耗统计

View File

@@ -0,0 +1,903 @@
# 注册界面 + 设置界面 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数据库迁移

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,982 @@
# Stats Dashboard 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:** 实现统计页面展示系统健康、对话趋势、知识库、看板、社区和个人洞察等6个Tab的指标数据。
**Architecture:**
- 后端6个统计API端点按模块分组
- 前端StatsView.vue 包含 6 个 Tab使用 ECharts 渲染折线图
- 数据聚合SQL GROUP BY date_trunc('day')
**Tech Stack:** FastAPI, SQLAlchemy, ECharts, Vue 3, Element Plus
---
## File Structure
```
backend/app/
├── routers/
│ └── stats.py # 新建: 统计 API 路由
├── services/
│ └── stats_service.py # 新建: 统计服务
└── schemas/
└── stats.py # 新建: 统计 Schema
frontend/src/
├── api/
│ └── stats.ts # 新建: 统计 API
├── views/
│ └── StatsView.vue # 新建: 统计页面
└── router/
└── index.ts # 修改: 添加 /stats 路由
```
---
## Task 1: Create Stats Schema
**Files:**
- Create: `backend/app/schemas/stats.py`
- [ ] **Step 1: Create stats schemas**
```python
# backend/app/schemas/stats.py
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
# ===== System Health =====
class SystemHealth(BaseModel):
uptime_seconds: int
cpu_percent: float
memory_used_mb: float
memory_total_mb: float
memory_percent: float
disk_used_gb: float
disk_total_gb: float
disk_percent: float
active_users_24h: int
# ===== Daily Stats Base =====
class DailyStatItem(BaseModel):
date: str
count: int
class DailyTokenStatItem(BaseModel):
date: str
input_tokens: int
output_tokens: int
# ===== Conversation Stats =====
class ConversationStats(BaseModel):
daily_conversations: list[DailyStatItem]
daily_messages: list[DailyStatItem]
daily_input_tokens: list[DailyTokenStatItem]
daily_output_tokens: list[DailyTokenStatItem]
totals: dict
# ===== Knowledge Stats =====
class KnowledgeStats(BaseModel):
daily_new_tags: list[DailyStatItem]
daily_documents: list[DailyStatItem]
daily_knowledge_queries: list[DailyStatItem]
daily_tag_relations: list[DailyStatItem]
totals: dict
# ===== Kanban Stats =====
class KanbanStats(BaseModel):
daily_new_tasks: list[DailyStatItem]
daily_completed_tasks: list[DailyStatItem]
daily_completion_rate: list[DailyStatItem]
current_pending_tasks: int
totals: dict
# ===== Community Stats =====
class CommunityStats(BaseModel):
daily_posts: list[DailyStatItem]
daily_replies: list[DailyStatItem]
daily_ai_executions: list[DailyStatItem]
daily_agent_calls: list[DailyStatItem]
totals: dict
# ===== Personal Insights =====
class HourlyActivity(BaseModel):
hour: int
count: int
class TagUsage(BaseModel):
tag_path: str
usage_count: int
class PersonalInsights(BaseModel):
hourly_activity: list[HourlyActivity]
top_tags: list[TagUsage]
token_trend_percent: float
this_month_tokens: int
last_month_tokens: int
```
---
## Task 2: Create Stats Service
**Files:**
- Create: `backend/app/services/stats_service.py`
- [ ] **Step 1: Create stats service**
```python
# backend/app/services/stats_service.py
import psutil
import time
from datetime import datetime, timedelta
from sqlalchemy import select, func, and_
from sqlalchemy.orm import Session
from app.models.conversation import Conversation, Message
from app.models.knowledge_graph import KGNode, KGEdge
from app.models.task import Task, TaskStatus
from app.models.forum import ForumPost, ForumReply
from app.models.document import Document
from app.models.user import User
class StatsService:
def __init__(self, db: Session):
self.db = db
def get_system_health(self) -> dict:
"""获取系统健康指标"""
# Uptime (假设进程启动时间)
uptime_seconds = int(time.time() - psutil.boot_time())
# CPU
cpu_percent = psutil.cpu_percent(interval=0.1)
# Memory
mem = psutil.virtual_memory()
memory_used_mb = mem.used / (1024 * 1024)
memory_total_mb = mem.total / (1024 * 1024)
memory_percent = mem.percent
# Disk
disk = psutil.disk_usage('/')
disk_used_gb = disk.used / (1024 * 1024 * 1024)
disk_total_gb = disk.total / (1024 * 1024 * 1024)
disk_percent = disk.percent
# Active users (24h)
yesterday = datetime.utcnow() - timedelta(days=1)
active_users = self.db.query(func.count(func.distinct(User.id))).filter(
User.updated_at >= yesterday
).scalar() or 0
return {
"uptime_seconds": uptime_seconds,
"cpu_percent": cpu_percent,
"memory_used_mb": round(memory_used_mb, 1),
"memory_total_mb": round(memory_total_mb, 1),
"memory_percent": memory_percent,
"disk_used_gb": round(disk_used_gb, 1),
"disk_total_gb": round(disk_total_gb, 1),
"disk_percent": disk_percent,
"active_users_24h": active_users,
}
def _get_daily_stats(self, model, date_column, user_id=None, days=30) -> list:
"""通用每日统计查询"""
cutoff = datetime.utcnow() - timedelta(days=days)
query = self.db.query(
func.date(date_column).label('date'),
func.count().label('count')
).filter(date_column >= cutoff)
if user_id:
query = query.filter(model.user_id == user_id)
query = query.group_by(func.date(date_column)).order_by(func.date(date_column))
results = query.all()
return [{"date": str(r.date), "count": r.count} for r in results]
def get_conversation_stats(self, user_id: str = None, days=30) -> dict:
"""获取对话统计数据"""
cutoff = datetime.utcnow() - timedelta(days=days)
# Daily conversations
daily_conversations = self._get_daily_stats(
Conversation, Conversation.created_at, user_id, days
)
# Daily messages
daily_messages = self._get_daily_stats(
Message, Message.created_at, user_id, days
)
# Daily tokens (input vs output - approximated by role)
input_query = self.db.query(
func.date(Message.created_at).label('date'),
func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
).filter(
Message.created_at >= cutoff,
Message.role == 'user'
)
if user_id:
input_query = input_query.join(Conversation).filter(Conversation.user_id == user_id)
input_query = input_query.group_by(func.date(Message.created_at))
input_results = input_query.all()
output_query = self.db.query(
func.date(Message.created_at).label('date'),
func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
).filter(
Message.created_at >= cutoff,
Message.role == 'assistant'
)
if user_id:
output_query = output_query.join(Conversation).filter(Conversation.user_id == user_id)
output_query = output_query.group_by(func.date(Message.created_at))
output_results = output_query.all()
daily_input_tokens = [
{"date": str(r.date), "input_tokens": r.tokens}
for r in input_results
]
daily_output_tokens = [
{"date": str(r.date), "output_tokens": r.tokens}
for r in output_results
]
total_conversations = sum(c["count"] for c in daily_conversations)
total_messages = sum(m["count"] for m in daily_messages)
total_input = sum(t["input_tokens"] for t in daily_input_tokens)
total_output = sum(t["output_tokens"] for t in daily_output_tokens)
return {
"daily_conversations": daily_conversations,
"daily_messages": daily_messages,
"daily_input_tokens": daily_input_tokens,
"daily_output_tokens": daily_output_tokens,
"totals": {
"conversations": total_conversations,
"messages": total_messages,
"input_tokens": total_input,
"output_tokens": total_output,
}
}
def get_knowledge_stats(self, user_id: str = None, days=30) -> dict:
"""获取知识库统计数据"""
cutoff = datetime.utcnow() - timedelta(days=days)
# New tags
daily_new_tags = self._get_daily_stats(
KGNode, KGNode.created_at, user_id, days
)
# Filter by tag type if user_id provided
if user_id:
tag_query = self.db.query(
func.date(KGNode.created_at).label('date'),
func.count().label('count')
).filter(
KGNode.created_at >= cutoff,
KGNode.user_id == user_id,
KGNode.entity_type == 'tag'
).group_by(func.date(KGNode.created_at))
daily_new_tags = [{"date": str(r.date), "count": r.count} for r in tag_query.all()]
# Documents
daily_documents = self._get_daily_stats(
Document, Document.created_at, user_id, days
)
# Tag relations
daily_tag_relations = self._get_daily_stats(
KGEdge, KGEdge.created_at, user_id, days
)
return {
"daily_new_tags": daily_new_tags,
"daily_documents": daily_documents,
"daily_knowledge_queries": [], # 需要 Chroma 查询日志
"daily_tag_relations": daily_tag_relations,
"totals": {
"new_tags": sum(t["count"] for t in daily_new_tags),
"documents": sum(d["count"] for d in daily_documents),
"tag_relations": sum(r["count"] for r in daily_tag_relations),
}
}
def get_kanban_stats(self, user_id: str = None, days=30) -> dict:
"""获取看板统计数据"""
cutoff = datetime.utcnow() - timedelta(days=days)
# New tasks
daily_new_tasks = self._get_daily_stats(
Task, Task.created_at, user_id, days
)
# Completed tasks
daily_completed = []
completed_query = self.db.query(
func.date(Task.completed_at).label('date'),
func.count().label('count')
).filter(
Task.completed_at >= cutoff,
Task.status == TaskStatus.DONE
)
if user_id:
completed_query = completed_query.filter(Task.user_id == user_id)
completed_query = completed_query.group_by(func.date(Task.completed_at))
daily_completed = [{"date": str(r.date), "count": r.count} for r in completed_query.all()]
# Current pending tasks
pending_count = self.db.query(func.count(Task.id)).filter(
Task.status == TaskStatus.TODO
)
if user_id:
pending_count = pending_count.filter(Task.user_id == user_id)
current_pending = pending_count.scalar() or 0
# Completion rate (daily)
daily_new_dict = {d["date"]: d["count"] for d in daily_new_tasks}
daily_completed_dict = {d["date"]: d["count"] for d in daily_completed}
all_dates = set(daily_new_dict.keys()) | set(daily_completed_dict.keys())
daily_completion_rate = []
for date in sorted(all_dates):
new = daily_new_dict.get(date, 0)
completed = daily_completed_dict.get(date, 0)
rate = (completed / new * 100) if new > 0 else 0
daily_completion_rate.append({"date": date, "rate": round(rate, 1)})
return {
"daily_new_tasks": daily_new_tasks,
"daily_completed_tasks": daily_completed,
"daily_completion_rate": daily_completion_rate,
"current_pending_tasks": current_pending,
"totals": {
"new_tasks": sum(t["count"] for t in daily_new_tasks),
"completed_tasks": sum(c["count"] for c in daily_completed),
}
}
def get_community_stats(self, user_id: str = None, days=30) -> dict:
"""获取社区统计数据"""
cutoff = datetime.utcnow() - timedelta(days=days)
# Posts
daily_posts = self._get_daily_stats(
ForumPost, ForumPost.created_at, user_id, days
)
# Replies
daily_replies = self._get_daily_stats(
ForumReply, ForumReply.created_at, user_id, days
)
# AI executions
daily_ai_executions = []
ai_query = self.db.query(
func.date(ForumPost.updated_at).label('date'),
func.count().label('count')
).filter(
ForumPost.updated_at >= cutoff,
ForumPost.is_executed == True
)
if user_id:
ai_query = ai_query.filter(ForumPost.user_id == user_id)
ai_query = ai_query.group_by(func.date(ForumPost.updated_at))
daily_ai_executions = [{"date": str(r.date), "count": r.count} for r in ai_query.all()]
return {
"daily_posts": daily_posts,
"daily_replies": daily_replies,
"daily_ai_executions": daily_ai_executions,
"daily_agent_calls": [], # 需要 AgentMessage 表
"totals": {
"posts": sum(p["count"] for p in daily_posts),
"replies": sum(r["count"] for r in daily_replies),
"ai_executions": sum(a["count"] for a in daily_ai_executions),
}
}
def get_personal_insights(self, user_id: str) -> dict:
"""获取个人洞察"""
# Hourly activity
hourly_query = self.db.query(
func.extract('hour', Conversation.created_at).label('hour'),
func.count().label('count')
).filter(
Conversation.user_id == user_id
).group_by(func.extract('hour', Conversation.created_at))
hourly_results = hourly_query.all()
hourly_activity = [{"hour": int(r.hour), "count": r.count} for r in hourly_results]
# Top tags
tag_query = self.db.query(
KGNode.properties_["tag_path"].astext.label('tag_path'),
func.count(KGEdge.id).label('usage_count')
).join(
KGEdge, KGEdge.target_id == KGNode.id
).filter(
KGNode.user_id == user_id,
KGNode.entity_type == 'tag',
KGEdge.relation_type == 'has_tag'
).group_by(
KGNode.properties_["tag_path"].astext
).order_by(func.count(KGEdge.id).desc()).limit(5)
top_tags = [{"tag_path": r.tag_path, "usage_count": r.usage_count} for r in tag_query.all()]
# Token trend (this month vs last month)
now = datetime.utcnow()
this_month_start = datetime(now.year, now.month, 1)
last_month_end = this_month_start - timedelta(days=1)
last_month_start = datetime(last_month_end.year, last_month_end.month, 1)
this_month_tokens = self.db.query(
func.coalesce(func.sum(Message.tokens_used), 0)
).join(Conversation).filter(
Conversation.user_id == user_id,
Message.created_at >= this_month_start,
Message.role == 'assistant'
).scalar() or 0
last_month_tokens = self.db.query(
func.coalesce(func.sum(Message.tokens_used), 0)
).join(Conversation).filter(
Conversation.user_id == user_id,
Message.created_at >= last_month_start,
Message.created_at < this_month_start,
Message.role == 'assistant'
).scalar() or 0
token_trend = 0
if last_month_tokens > 0:
token_trend = round((this_month_tokens - last_month_tokens) / last_month_tokens * 100, 1)
return {
"hourly_activity": hourly_activity,
"top_tags": top_tags,
"token_trend_percent": token_trend,
"this_month_tokens": this_month_tokens,
"last_month_tokens": last_month_tokens,
}
```
---
## Task 3: Create Stats Router
**Files:**
- Create: `backend/app/routers/stats.py`
- [ ] **Step 1: Create stats router**
```python
# backend/app/routers/stats.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.stats import (
SystemHealth,
ConversationStats,
KnowledgeStats,
KanbanStats,
CommunityStats,
PersonalInsights,
)
from app.services.stats_service import StatsService
router = APIRouter(prefix="/api/stats", tags=["统计"])
@router.get("/system", response_model=SystemHealth)
async def get_system_health(db: Session = Depends(get_db)):
"""获取系统健康指标"""
svc = StatsService(db)
return svc.get_system_health()
@router.get("/conversations", response_model=ConversationStats)
async def get_conversation_stats(
days: int = 30,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取对话统计数据"""
svc = StatsService(db)
return svc.get_conversation_stats(user_id=current_user.id, days=days)
@router.get("/knowledge", response_model=KnowledgeStats)
async def get_knowledge_stats(
days: int = 30,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取知识库统计数据"""
svc = StatsService(db)
return svc.get_knowledge_stats(user_id=current_user.id, days=days)
@router.get("/kanban", response_model=KanbanStats)
async def get_kanban_stats(
days: int = 30,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取看板统计数据"""
svc = StatsService(db)
return svc.get_kanban_stats(user_id=current_user.id, days=days)
@router.get("/community", response_model=CommunityStats)
async def get_community_stats(
days: int = 30,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取社区统计数据"""
svc = StatsService(db)
return svc.get_community_stats(user_id=current_user.id, days=days)
@router.get("/insights", response_model=PersonalInsights)
async def get_personal_insights(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取个人洞察"""
svc = StatsService(db)
return svc.get_personal_insights(user_id=current_user.id)
```
- [ ] **Step 2: Register router in main app**
`backend/app/__init__.py``main.py` 中添加:
```python
from app.routers import stats
app.include_router(stats.router)
```
---
## Task 4: Create Frontend API
**Files:**
- Create: `frontend/src/api/stats.ts`
- [ ] **Step 1: Create stats API**
```typescript
// frontend/src/api/stats.ts
import axios from '@/api'
export const getSystemHealth = () => axios.get('/stats/system')
export const getConversationStats = (days = 30) =>
axios.get('/stats/conversations', { params: { days } })
export const getKnowledgeStats = (days = 30) =>
axios.get('/stats/knowledge', { params: { days } })
export const getKanbanStats = (days = 30) =>
axios.get('/stats/kanban', { params: { days } })
export const getCommunityStats = (days = 30) =>
axios.get('/stats/community', { params: { days } })
export const getPersonalInsights = () => axios.get('/stats/insights')
```
---
## Task 5: Create StatsView Component
**Files:**
- Create: `frontend/src/views/StatsView.vue`
- [ ] **Step 1: Create StatsView with 6 tabs**
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as statsApi from '@/api/stats'
import * as echarts from 'echarts'
import {
Cpu, HardDrive, MemoryStick, Users, Activity,
MessageSquare, BookOpen, CheckSquare, Forum,
TrendingUp, Clock, Tag, Zap
} from 'lucide-vue-next'
const activeTab = ref('system')
const systemHealth = ref<any>(null)
const conversationStats = ref<any>(null)
const knowledgeStats = ref<any>(null)
const kanbanStats = ref<any>(null)
const communityStats = ref<any>(null)
const personalInsights = ref<any>(null)
// Format uptime
function formatUptime(seconds: number) {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
return `${days}d ${hours}h ${mins}m`
}
// Chart refs
const convChartRef = ref<HTMLElement>()
const knowChartRef = ref<HTMLElement>()
const kanbanChartRef = ref<HTMLElement>()
const communityChartRef = ref<HTMLElement>()
const hourlyChartRef = ref<HTMLElement>()
onMounted(async () => {
const [sys, conv, know, kanban, community, insights] = await Promise.all([
statsApi.getSystemHealth(),
statsApi.getConversationStats(),
statsApi.getKnowledgeStats(),
statsApi.getKanbanStats(),
statsApi.getCommunityStats(),
statsApi.getPersonalInsights(),
])
systemHealth.value = sys.data
conversationStats.value = conv.data
knowledgeStats.value = know.data
kanbanStats.value = kanban.data
communityStats.value = community.data
personalInsights.value = insights.data
// Render charts
renderLineChart(convChartRef.value, conv.data)
renderLineChart(knowChartRef.value, know.data)
renderKanbanChart(kanbanChartRef.value, kanban.data)
renderLineChart(communityChartRef.value, community.data)
renderHourlyChart(hourlyChartRef.value, insights.data)
})
function renderLineChart(el: HTMLElement, data: any) {
if (!el || !data) return
const chart = echarts.init(el)
const dates = data.daily_conversations?.map((d: any) => d.date) || []
const option = {
tooltip: { trigger: 'axis' },
legend: { data: Object.keys(data).filter(k => k.startsWith('daily_')) },
xAxis: { type: 'category', data: dates },
yAxis: { type: 'value' },
series: Object.entries(data).filter(([k]) => k.startsWith('daily_')).map(([name, values]) => ({
name: name.replace('daily_', ''),
type: 'line',
data: (values as any[]).map((v: any) => v.count || v.input_tokens || v.output_tokens || 0)
}))
}
chart.setOption(option)
}
function renderKanbanChart(el: HTMLElement, data: any) {
if (!el || !data) return
const chart = echarts.init(el)
const dates = [...new Set([
...data.daily_new_tasks.map((d: any) => d.date),
...data.daily_completed_tasks.map((d: any) => d.date)
])].sort()
option = {
tooltip: { trigger: 'axis' },
legend: { data: ['新建任务', '完成任务'] },
xAxis: { type: 'category', data: dates },
yAxis: { type: 'value' },
series: [
{ name: '新建任务', type: 'bar', data: dates.map(d => data.daily_new_tasks.find((t: any) => t.date === d)?.count || 0) },
{ name: '完成任务', type: 'bar', data: dates.map(d => data.daily_completed_tasks.find((t: any) => t.date === d)?.count || 0) }
]
}
chart.setOption(option)
}
function renderHourlyChart(el: HTMLElement, data: any) {
if (!el || !data) return
const chart = echarts.init(el)
const hours = Array.from({ length: 24 }, (_, i) => i)
const counts = hours.map(h => data.hourly_activity.find((a: any) => a.hour === h)?.count || 0)
chart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: hours.map(h => `${h}:00`) },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: counts }]
})
}
</script>
<template>
<div class="stats-view">
<div class="stats-header">
<h1>数据统计</h1>
</div>
<el-tabs v-model="activeTab" type="border-card">
<!-- Tab 1: System Health -->
<el-tab-pane label="系统健康" name="system">
<div class="metrics-grid" v-if="systemHealth">
<div class="metric-card">
<div class="metric-icon"><Clock /></div>
<div class="metric-info">
<span class="metric-value">{{ formatUptime(systemHealth.uptime_seconds) }}</span>
<span class="metric-label">运行时间</span>
</div>
</div>
<div class="metric-card">
<div class="metric-icon"><Cpu /></div>
<div class="metric-info">
<span class="metric-value">{{ systemHealth.cpu_percent }}%</span>
<span class="metric-label">CPU 使用率</span>
</div>
</div>
<div class="metric-card">
<div class="metric-icon"><MemoryStick /></div>
<div class="metric-info">
<span class="metric-value">{{ systemHealth.memory_percent }}%</span>
<span class="metric-label">内存占用</span>
</div>
</div>
<div class="metric-card">
<div class="metric-icon"><HardDrive /></div>
<div class="metric-info">
<span class="metric-value">{{ systemHealth.disk_percent }}%</span>
<span class="metric-label">磁盘使用</span>
</div>
</div>
<div class="metric-card">
<div class="metric-icon"><Users /></div>
<div class="metric-info">
<span class="metric-value">{{ systemHealth.active_users_24h }}</span>
<span class="metric-label">活跃用户(24h)</span>
</div>
</div>
</div>
</el-tab-pane>
<!-- Tab 2: Conversations -->
<el-tab-pane label="对话统计" name="conversations">
<div class="chart-container" ref="convChartRef"></div>
<div class="totals-row" v-if="conversationStats">
<div class="total-item">
<span class="total-value">{{ conversationStats.totals.conversations }}</span>
<span class="total-label">对话总数</span>
</div>
<div class="total-item">
<span class="total-value">{{ conversationStats.totals.messages }}</span>
<span class="total-label">消息总数</span>
</div>
<div class="total-item">
<span class="total-value">{{ conversationStats.totals.input_tokens }}</span>
<span class="total-label">Input Tokens</span>
</div>
<div class="total-item">
<span class="total-value">{{ conversationStats.totals.output_tokens }}</span>
<span class="total-label">Output Tokens</span>
</div>
</div>
</el-tab-pane>
<!-- Tab 3: Knowledge -->
<el-tab-pane label="知识库" name="knowledge">
<div class="chart-container" ref="knowChartRef"></div>
</el-tab-pane>
<!-- Tab 4: Kanban -->
<el-tab-pane label="看板" name="kanban">
<div class="chart-container" ref="kanbanChartRef"></div>
<div class="totals-row" v-if="kanbanStats">
<div class="total-item">
<span class="total-value">{{ kanbanStats.current_pending_tasks }}</span>
<span class="total-label">待办任务</span>
</div>
</div>
</el-tab-pane>
<!-- Tab 5: Community -->
<el-tab-pane label="社区" name="community">
<div class="chart-container" ref="communityChartRef"></div>
</el-tab-pane>
<!-- Tab 6: Personal Insights -->
<el-tab-pane label="个人洞察" name="insights">
<div class="insights-grid" v-if="personalInsights">
<div class="insight-card">
<h3>活跃时段</h3>
<div class="chart-small" ref="hourlyChartRef"></div>
</div>
<div class="insight-card">
<h3>常用标签 Top5</h3>
<ul class="tag-list">
<li v-for="tag in personalInsights.top_tags" :key="tag.tag_path">
<Tag /> {{ tag.tag_path }} ({{ tag.usage_count }})
</li>
</ul>
</div>
<div class="insight-card">
<h3>Token 消耗趋势</h3>
<div class="trend-value" :class="personalInsights.token_trend_percent > 0 ? 'up' : 'down'">
<TrendingUp /> {{ personalInsights.token_trend_percent }}%
</div>
<p>本月 vs 上月</p>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.stats-view {
padding: 24px;
}
.stats-header h1 {
font-size: 24px;
margin-bottom: 24px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.metric-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--bg-panel);
border-radius: 8px;
border: 1px solid var(--border-dim);
}
.metric-icon {
color: var(--accent-cyan);
}
.metric-info {
display: flex;
flex-direction: column;
}
.metric-value {
font-size: 20px;
font-weight: 600;
}
.metric-label {
font-size: 12px;
color: var(--text-dim);
}
.chart-container {
height: 300px;
margin-bottom: 24px;
}
.totals-row {
display: flex;
gap: 24px;
}
.total-item {
display: flex;
flex-direction: column;
}
.total-value {
font-size: 24px;
font-weight: 600;
}
.total-label {
font-size: 12px;
color: var(--text-dim);
}
.insights-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.insight-card {
background: var(--bg-panel);
padding: 16px;
border-radius: 8px;
border: 1px solid var(--border-dim);
}
.insight-card h3 {
margin-bottom: 12px;
}
.chart-small {
height: 200px;
}
.tag-list {
list-style: none;
padding: 0;
}
.tag-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-dim);
}
.trend-value {
font-size: 32px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.trend-value.up { color: var(--accent-red); }
.trend-value.down { color: var(--accent-green); }
</style>
```
---
## Task 6: Add Route and Navigation
**Files:**
- Modify: `frontend/src/router/index.ts`
- Modify: `frontend/src/components/SidebarNav.vue`
- [ ] **Step 1: Add route**
```typescript
// frontend/src/router/index.ts
{
path: 'stats',
name: 'stats',
component: () => import('@/views/StatsView.vue'),
},
```
- [ ] **Step 2: Add navigation item**
```typescript
// frontend/src/components/SidebarNav.vue
// Add to navItems array:
{ name: '统计', path: '/stats', icon: Activity },
```
---
## Summary
| Task | Description | Files |
|------|-------------|-------|
| 1 | Stats Schema | `schemas/stats.py` |
| 2 | Stats Service | `services/stats_service.py` |
| 3 | Stats Router | `routers/stats.py` |
| 4 | Frontend API | `api/stats.ts` |
| 5 | StatsView Component | `views/StatsView.vue` |
| 6 | Route & Navigation | `router/index.ts`, `SidebarNav.vue` |

View File

@@ -0,0 +1,739 @@
# Tag System 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:** 实现一个层级标签系统,标签作为 KGNodeentity_type="tag"),支持 AI 自动生成标签、标签关系网络、内容关联发现。
**Architecture:**
- 标签存储为 KGNodeentity_type="tag"使用路径格式tag_path表示层级
- 标签关系通过 KGEdge 表示parent_of, related_to, synonym_of
- 内容节点通过 `properties_.tag_node_ids` 关联到标签节点
- AI 服务自动从内容中提取标签并建立标签关系
**Tech Stack:** FastAPI, SQLAlchemy, LLM API
---
## File Structure
```
backend/app/
├── models/
│ └── knowledge_graph.py # 修改: KGNode 支持 tag 节点
├── schemas/
│ └── graph.py # 修改: 新增 TagSchema
├── services/
│ ├── tag_service.py # 新建: 标签提取与管理服务
│ └── __init__.py
└── routers/
└── graph.py # 修改: 新增标签相关 API
```
---
## Task 1: Extend KGNode Properties for Tags
**Files:**
- Modify: `backend/app/models/knowledge_graph.py:1-33`
- Modify: `backend/app/schemas/graph.py:1-35`
- [ ] **Step 1: Add TagProperties schema**
```python
# backend/app/schemas/graph.py
from pydantic import BaseModel, Field
from typing import Literal
class TagProperties(BaseModel):
tag_path: str = Field(..., description="完整标签路径,如 '编程语言/Python/异步'")
short_name: str = Field(..., description="显示名称,如 '异步'")
level: int = Field(..., ge=1, description="层级深度1为顶级")
parent_path: str | None = Field(None, description="父路径,如 '编程语言/Python'")
description: str | None = Field(None, description="AI生成的标签描述")
color: str | None = Field(None, description="标签颜色,如 '#FF5733'")
```
- [ ] **Step 2: Update KGNodeOut schema**
```python
class KGNodeOut(BaseModel):
id: str
name: str
entity_type: str
description: str | None
properties_: dict | None
importance: float
created_at: str
# 新增:如果是 tag 节点,返回 tag 属性
tag_properties: TagProperties | None = None
model_config = {"from_attributes": True}
def model_post_init(self, __context):
if self.entity_type == "tag" and self.properties_:
self.tag_properties = TagProperties(**self.properties_)
```
- [ ] **Step 3: Update KGNode model comment**
`KGNode` 类的 `entity_type` 注释中新增 `"tag"` 选项:
```python
entity_type = Column(String(100), nullable=False) # person, concept, task, document, chunk, tag
```
- [ ] **Step 4: Commit**
```bash
git add backend/app/models/knowledge_graph.py backend/app/schemas/graph.py
git commit -m "feat: extend KGNode for tag support with path-based hierarchy"
```
---
## Task 2: Create Tag Service
**Files:**
- Create: `backend/app/services/tag_service.py`
- [ ] **Step 1: Write tag extraction prompt template**
```python
# backend/app/services/tag_service.py
TAG_EXTRACTION_PROMPT = """你是一个知识分类专家。从给定内容中提取标签。
要求:
1. 标签采用层级路径格式,如 "编程语言/Python""后端/框架/FastAPI"
2. 层级深度 1-4 层,避免过深
3. 每个内容提取 3-8 个标签
4. 标签应覆盖:主题、技术栈、领域、任务类型等维度
输出格式JSON数组
[
{{"path": "编程语言/Python", "description": "Python编程语言相关"}},
{{"path": "后端/框架/FastAPI", "description": "FastAPI框架相关"}}
]
内容:
{content}
"""
TAG_RELATION_PROMPT = """分析以下标签之间的关系,输出 JSON 数组:
关系类型:
- parent_of: 父子关系(上级包含下级)
- related_to: 语义相关(但不是父子)
- synonym_of: 同义词
标签列表:
{tag_paths}
输出格式:
[
{{"source": "标签1", "target": "标签2", "relation": "related_to", "weight": 0.8}},
{{"source": "标签1", "target": "标签3", "relation": "parent_of", "weight": 1.0}}
]
"""
```
- [ ] **Step 2: Write TagService class**
```python
# backend/app/services/tag_service.py
import json
import re
from typing import Annotated
from sqlalchemy.orm import Session
from app.models.knowledge_graph import KGNode, KGEdge
class TagService:
def __init__(self, db: Session, llm_client):
self.db = db
self.llm_client = llm_client
def extract_tags_from_content(
self, content: str, user_id: str
) -> list[dict]:
"""从内容中提取标签"""
response = self.llm_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "你是一个知识分类专家。"},
{"role": "user", "content": TAG_EXTRACTION_PROMPT.format(content=content)}
],
response_format={"type": "json_object"}
)
result = json.loads(response.choices[0].message.content)
return result.get("tags", [])
def parse_tag_path(self, path: str) -> tuple[str, int, str | None]:
"""解析标签路径,返回 (short_name, level, parent_path)"""
parts = path.strip("/").split("/")
short_name = parts[-1]
level = len(parts)
parent_path = "/".join(parts[:-1]) if level > 1 else None
return short_name, level, parent_path
def get_or_create_tag_node(
self, tag_info: dict, user_id: str
) -> KGNode:
"""获取或创建标签节点"""
path = tag_info["path"]
existing = self.db.query(KGNode).filter(
KGNode.user_id == user_id,
KGNode.properties_["tag_path"].astext == path
).first()
if existing:
return existing
short_name, level, parent_path = self.parse_tag_path(path)
node = KGNode(
user_id=user_id,
name=short_name,
entity_type="tag",
description=tag_info.get("description"),
properties_={
"tag_path": path,
"short_name": short_name,
"level": level,
"parent_path": parent_path,
"description": tag_info.get("description"),
"color": tag_info.get("color"),
},
importance=0.5
)
self.db.add(node)
self.db.flush()
return node
def ensure_parent_tags(self, path: str, user_id: str) -> list[KGNode]:
"""确保父路径标签存在"""
parts = path.strip("/").split("/")
nodes = []
for i in range(1, len(parts)):
parent_path = "/".join(parts[:i])
tag_info = {"path": parent_path, "description": None}
node = self.get_or_create_tag_node(tag_info, user_id)
nodes.append(node)
return nodes
def create_tag_relations(
self, tag_paths: list[str], user_id: str
) -> list[KGEdge]:
"""分析并创建标签之间的关系边"""
# 构建标签节点映射
path_to_node = {}
for path in tag_paths:
node = self.db.query(KGNode).filter(
KGNode.user_id == user_id,
KGNode.properties_["tag_path"].astext == path,
KGNode.entity_type == "tag"
).first()
if node:
path_to_node[path] = node
# 调用 LLM 分析关系
response = self.llm_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "你是一个知识图谱专家。"},
{"role": "user", "content": TAG_RELATION_PROMPT.format(tag_paths=json.dumps(tag_paths))}
],
response_format={"type": "json_object"}
)
result = json.loads(response.choices[0].message.content)
relations = result.get("relations", [])
edges = []
for rel in relations:
source_node = path_to_node.get(rel["source"])
target_node = path_to_node.get(rel["target"])
if source_node and target_node:
# 检查边是否已存在
existing = self.db.query(KGEdge).filter(
KGEdge.source_id == source_node.id,
KGEdge.target_id == target_node.id
).first()
if not existing:
edge = KGEdge(
source_id=source_node.id,
target_id=target_node.id,
relation_type=rel["relation"],
weight=rel.get("weight", 0.5)
)
self.db.add(edge)
edges.append(edge)
self.db.flush()
return edges
def tag_content(
self, content: str, user_id: str, content_node: KGNode
) -> list[KGNode]:
"""为内容节点打标签"""
# 1. 提取标签
tag_infos = self.extract_tags_from_content(content, user_id)
tag_paths = [t["path"] for t in tag_infos]
# 2. 创建/获取标签节点(包含父路径)
tag_nodes = []
for tag_info in tag_infos:
node = self.get_or_create_tag_node(tag_info, user_id)
tag_nodes.append(node)
# 确保父标签存在
self.ensure_parent_tags(tag_info["path"], user_id)
# 3. 更新内容节点的 tag_node_ids
tag_node_ids = [n.id for n in tag_nodes]
current_tag_ids = content_node.properties_.get("tag_node_ids", [])
content_node.properties_["tag_node_ids"] = list(set(current_tag_ids + tag_node_ids))
# 4. 分析并创建标签关系
if len(tag_paths) >= 2:
self.create_tag_relations(tag_paths, user_id)
self.db.commit()
return tag_nodes
def get_related_content(
self, tag_node_ids: list[str], user_id: str, limit: int = 10
) -> list[tuple[KGNode, float]]:
"""通过标签找相关内容"""
# 找所有关联到这些标签的内容节点
edges = self.db.query(KGEdge).filter(
KGEdge.target_id.in_(tag_node_ids),
KGEdge.relation_type == "has_tag"
).all()
# 统计共现次数作为权重
content_weights: dict[str, float] = {}
for edge in edges:
content_weights[edge.source_id] = content_weights.get(edge.source_id, 0) + edge.weight
# 获取内容节点
content_ids = list(content_weights.keys())
content_nodes = self.db.query(KGNode).filter(
KGNode.id.in_(content_ids),
KGNode.entity_type.in_(["conversation", "document", "chunk"])
).all()
return [(node, content_weights[node.id]) for node in content_nodes]
```
- [ ] **Step 3: Commit**
```bash
git add backend/app/services/tag_service.py
git commit -m "feat: add TagService for AI-powered tag extraction and management"
```
---
## Task 3: Add Tag API Routes
**Files:**
- Modify: `backend/app/routers/graph.py`
- [ ] **Step 1: Read existing router**
```bash
cat backend/app/routers/graph.py
```
- [ ] **Step 2: Add tag-related schemas**
```python
# 在 graph.py 末尾添加
class TagExtractRequest(BaseModel):
content: str = Field(..., min_length=10)
user_id: str
class TagExtractResponse(BaseModel):
tags: list[TagProperties]
tag_count: int
class TagRelationRequest(BaseModel):
tag_paths: list[str] = Field(..., min_length=2)
user_id: str
class RelatedContentRequest(BaseModel):
tag_ids: list[str]
user_id: str
limit: int = 10
```
- [ ] **Step 3: Add tag endpoints**
```python
@router.post("/tags/extract", response_model=TagExtractResponse)
async def extract_tags(request: TagExtractRequest, db: Session = Depends(get_db)):
"""从内容中提取标签(不保存到节点)"""
# 需要注入 LLM client
from app.services.tag_service import TagService
from app.core.llm import get_llm_client
llm_client = get_llm_client()
tag_service = TagService(db, llm_client)
tag_infos = tag_service.extract_tags_from_content(request.content, request.user_id)
tags = []
for t in tag_infos:
short_name, level, parent_path = tag_service.parse_tag_path(t["path"])
tags.append(TagProperties(
tag_path=t["path"],
short_name=short_name,
level=level,
parent_path=parent_path,
description=t.get("description")
))
return TagExtractResponse(tags=tags, tag_count=len(tags))
@router.post("/tags/content/{node_id}", response_model=TagExtractResponse)
async def tag_content_node(
node_id: str,
request: TagExtractRequest,
db: Session = Depends(get_db)
):
"""为内容节点打标签"""
from app.services.tag_service import TagService
from app.core.llm import get_llm_client
node = db.query(KGNode).filter(KGNode.id == node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
llm_client = get_llm_client()
tag_service = TagService(db, llm_client)
tag_nodes = tag_service.tag_content(request.content, request.user_id, node)
tags = []
for n in tag_nodes:
props = n.properties_ or {}
tags.append(TagProperties(
tag_path=props.get("tag_path", n.name),
short_name=n.name,
level=props.get("level", 1),
parent_path=props.get("parent_path"),
description=n.description
))
return TagExtractResponse(tags=tags, tag_count=len(tags))
@router.get("/tags/{user_id}", response_model=list[KGNodeOut])
async def get_user_tags(user_id: str, db: Session = Depends(get_db)):
"""获取用户的所有标签"""
nodes = db.query(KGNode).filter(
KGNode.user_id == user_id,
KGNode.entity_type == "tag"
).order_by(KGNode.properties_["level"].astext).all()
return nodes
@router.get("/tags/{tag_id}/related", response_model=list[KGNodeOut])
async def get_related_tags(tag_id: str, db: Session = Depends(get_db)):
"""获取标签的关联标签"""
# 获取该标签的所有关联边
edges = db.query(KGEdge).filter(
or_(KGEdge.source_id == tag_id, KGEdge.target_id == tag_id),
KGEdge.relation_type.in_(["related_to", "synonym_of"])
).all()
related_ids = set()
for e in edges:
if e.source_id == tag_id:
related_ids.add(e.target_id)
else:
related_ids.add(e.source_id)
if not related_ids:
return []
nodes = db.query(KGNode).filter(KGNode.id.in_(related_ids)).all()
return nodes
@router.post("/content/related", response_model=list[KGNodeOut])
async def get_related_content(
request: RelatedContentRequest,
db: Session = Depends(get_db)
):
"""通过标签找相关内容"""
from app.services.tag_service import TagService
from app.core.llm import get_llm_client
llm_client = get_llm_client()
tag_service = TagService(db, llm_client)
results = tag_service.get_related_content(request.tag_ids, request.user_id, request.limit)
nodes = [r[0] for r in results]
return nodes
```
- [ ] **Step 4: Commit**
```bash
git add backend/app/routers/graph.py
git commit -m "feat: add tag API routes for extraction and retrieval"
```
---
## Task 4: Add has_tag Edge Creation
**Files:**
- Modify: `backend/app/services/tag_service.py`
- [ ] **Step 1: Add has_tag edge when tagging content**
`tag_content` 方法中,添加创建 `has_tag` 边的逻辑:
```python
# 在 `tag_content` 方法中,`更新内容节点的 tag_node_ids` 之前添加:
# 创建 has_tag 边
for tag_node in tag_nodes:
existing_edge = self.db.query(KGEdge).filter(
KGEdge.source_id == content_node.id,
KGEdge.target_id == tag_node.id,
KGEdge.relation_type == "has_tag"
).first()
if not existing_edge:
edge = KGEdge(
source_id=content_node.id,
target_id=tag_node.id,
relation_type="has_tag",
weight=1.0
)
self.db.add(edge)
```
- [ ] **Step 2: Commit**
```bash
git add backend/app/services/tag_service.py
git commit -m "fix: create has_tag edges when tagging content"
```
---
## Task 5: Write Unit Tests
**Files:**
- Create: `tests/backend/app/services/test_tag_service.py`
- [ ] **Step 1: Write tests**
```python
import pytest
from unittest.mock import MagicMock, patch
from app.services.tag_service import TagService
class TestTagService:
def test_parse_tag_path_single_level(self):
service = TagService(db=MagicMock(), llm_client=MagicMock())
short_name, level, parent_path = service.parse_tag_path("Python")
assert short_name == "Python"
assert level == 1
assert parent_path is None
def test_parse_tag_path_nested(self):
service = TagService(db=MagicMock(), llm_client=MagicMock())
short_name, level, parent_path = service.parse_tag_path("编程语言/Python/异步")
assert short_name == "异步"
assert level == 3
assert parent_path == "编程语言/Python"
def test_parse_tag_path_strips_slashes(self):
service = TagService(db=MagicMock(), llm_client=MagicMock())
short_name, level, parent_path = service.parse_tag_path("/后端/框架/")
assert short_name == "框架"
assert level == 2
assert parent_path == "后端"
@patch('app.services.tag_service.KGNode')
@patch('app.services.tag_service.KGEdge')
def test_get_or_create_tag_node_creates_new(self, mock_edge, mock_node):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
service = TagService(db=mock_db, llm_client=MagicMock())
tag_info = {"path": "Python", "description": "Python语言"}
result = service.get_or_create_tag_node(tag_info, "user_123")
assert result is not None
mock_db.add.assert_called_once()
mock_db.flush.assert_called_once()
@patch('app.services.tag_service.KGNode')
def test_get_or_create_tag_node_returns_existing(self, mock_node):
mock_db = MagicMock()
mock_existing = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_existing
service = TagService(db=mock_db, llm_client=MagicMock())
tag_info = {"path": "Python", "description": "Python语言"}
result = service.get_or_create_tag_node(tag_info, "user_123")
assert result == mock_existing
mock_db.add.assert_not_called()
```
- [ ] **Step 2: Run tests**
```bash
cd backend && python -m pytest tests/backend/app/services/test_tag_service.py -v
```
- [ ] **Step 3: Commit**
```bash
git add tests/backend/app/services/test_tag_service.py
git commit -m "test: add unit tests for TagService"
```
---
---
## Task 6: Add Scheduled Tag Generation Job
**Files:**
- Modify: `backend/app/services/scheduler_service.py:1-211`
- Modify: `backend/app/routers/scheduler.py:1-41`
- [ ] **Step 1: Add async tag service method**
`TagService` 中添加增量打标签方法:
```python
# 在 tag_service.py 添加
async def tag_incremental_content(self, user_id: str, days: int = 1) -> dict:
"""
增量打标签 - 只对最近新增/更新的内容节点打标签
- days: 追溯最近多少天内的内容
"""
from datetime import datetime, timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days)
# 查找尚未打标签的内容节点tag_node_ids 为空或未设置)
content_nodes = self.db.query(KGNode).filter(
KGNode.user_id == user_id,
KGNode.entity_type.in_(["conversation", "document", "chunk"]),
KGNode.updated_at >= cutoff_date
).all()
# 过滤掉已有标签的节点
untagged = [
n for n in content_nodes
if not n.properties_.get("tag_node_ids")
]
tagged_count = 0
for node in untagged:
content = node.description or ""
try:
self.tag_content(content, user_id, node)
tagged_count += 1
except Exception as e:
logger.warning(f"标签化节点 {node.id} 失败: {e}")
return {"total": len(untagged), "tagged": tagged_count}
```
- [ ] **Step 2: Add scheduled job function**
`scheduler_service.py` 中添加:
```python
async def tag_generation_task():
"""
每日凌晨 00:00 增量标签生成任务
- 扫描最近 24 小时新增/更新的内容
- AI 自动提取标签
- 建立标签关系网络
"""
logger.info("[Scheduler] 开始执行每日标签生成...")
async with async_session() as db:
try:
from app.services.tag_service import TagService
from app.core.llm import get_llm_client
llm_client = get_llm_client()
tag_service = TagService(db, llm_client)
# 获取所有有内容的用户
result = await db.execute(
select(KGNode.user_id).distinct().where(
KGNode.entity_type.in_(["conversation", "document", "chunk"])
)
)
user_ids = result.scalars().all()
total_tagged = 0
for user_id in user_ids:
# 同步调用增量打标签
sync_tag_service = TagService(db, llm_client)
result = sync_tag_service.tag_incremental_content(user_id, days=1)
total_tagged += result["tagged"]
logger.info(f"[Scheduler] 每日标签生成完成,共标签化 {total_tagged} 个内容节点")
except Exception as e:
logger.error(f"[Scheduler] 每日标签生成失败: {e}")
```
- [ ] **Step 3: Register the scheduled job**
`start_scheduler()` 函数中添加:
```python
# 每天凌晨 00:00 生成标签
scheduler.add_job(
tag_generation_task,
CronTrigger(hour=0, minute=0, timezone="Asia/Shanghai"),
id="tag_generation",
name="每日标签生成",
replace_existing=True,
)
```
- [ ] **Step 4: Update scheduler router**
`routers/scheduler.py``job_map` 中添加:
```python
job_map = {
"daily_task_analysis": daily_task_analysis,
"forum_scan": forum_scan_task,
"graph_rebuild": graph_rebuild_task,
"tag_generation": tag_generation_task, # 新增
}
```
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/scheduler_service.py backend/app/routers/scheduler.py
git commit -m "feat: add daily scheduled tag generation at midnight"
```
---
## Summary
| Task | Description | Files |
|------|-------------|-------|
| 1 | Extend KGNode for tag support | `models/knowledge_graph.py`, `schemas/graph.py` |
| 2 | Create TagService for AI tag extraction | `services/tag_service.py` |
| 3 | Add tag API routes | `routers/graph.py` |
| 4 | Fix has_tag edge creation | `services/tag_service.py` |
| 5 | Write unit tests | `tests/backend/app/services/test_tag_service.py` |
| 6 | Add scheduled tag generation (00:00) | `services/scheduler_service.py`, `routers/scheduler.py` |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,941 @@
# 知识库文件夹分层实施计划
> **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:** 为知识库添加文件夹分层组织功能支持多层嵌套、CRUD、级联删除
**Architecture:** 使用邻接表模式parent_id存储文件夹层级通过递归 CTE 查询完整树结构。ChromaDB metadata 增加 folder_path 支持按文件夹过滤检索。
**Tech Stack:** FastAPI + SQLAlchemy async + SQLite + ChromaDB + Vue 3 + TypeScript
---
## Task 1: 创建 Folder 模型
**Files:**
- Create: `backend/app/models/folder.py`
- Test: `backend/app/models/test_folder.py`
- [ ] **Step 1: 创建 Folder 模型**
```python
# backend/app/models/folder.py
from sqlalchemy import Column, String, ForeignKey, UniqueConstraint
from app.models.base import BaseModel
class Folder(BaseModel):
__tablename__ = "folders"
__table_args__ = (
UniqueConstraint('user_id', 'parent_id', 'name', name='uq_user_parent_name'),
)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(255), nullable=False)
parent_id = Column(String(36), ForeignKey("folders.id"), nullable=True)
```
- [ ] **Step 2: 创建测试文件验证模型**
```python
# backend/app/models/test_folder.py
import pytest
from app.models.folder import Folder
def test_folder_model_creation():
folder = Folder(user_id="test-user", name="Test Folder")
assert folder.name == "Test Folder"
assert folder.parent_id is None
```
- [ ] **Step 3: 提交**
---
## Task 2: 创建 Folder Schema
**Files:**
- Create: `backend/app/schemas/folder.py`
- [ ] **Step 1: 创建 Pydantic schemas**
```python
# backend/app/schemas/folder.py
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from datetime import datetime
class FolderCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
parent_id: Optional[str] = None
@field_validator('name')
@classmethod
def validate_name(cls, v):
forbidden = '/\\*?:'
for c in forbidden:
if c in v:
raise ValueError(f'Folder name cannot contain: {forbidden}')
return v
class FolderUpdate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
class FolderOut(BaseModel):
id: str
name: str
parent_id: Optional[str]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class FolderTreeOut(BaseModel):
id: str
name: str
parent_id: Optional[str]
children: List["FolderTreeOut"] = []
model_config = {"from_attributes": True}
# 递归模型需要 forward ref
FolderTreeOut.model_rebuild()
```
- [ ] **Step 2: 提交**
---
## Task 3: 创建文件夹路由
**Files:**
- Create: `backend/app/routers/folder.py`
- [ ] **Step 1: 实现文件夹 CRUD 路由**
```python
# backend/app/routers/folder.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import List
from app.database import get_db
from app.models.folder import Folder
from app.models.user import User
from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut
from app.services.auth_service import get_current_user
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
"""递归构建文件夹树"""
tree = []
for folder in folders:
if folder.parent_id == parent_id:
children = build_folder_tree(folders, folder.id)
tree.append(FolderTreeOut(
id=folder.id,
name=folder.name,
parent_id=folder.parent_id,
children=children
))
return tree
@router.get("", response_model=List[FolderTreeOut])
async def get_folders(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取用户的完整文件夹树"""
result = await db.execute(
select(Folder).where(Folder.user_id == current_user.id)
)
folders = result.scalars().all()
return build_folder_tree(list(folders))
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
async def create_folder(
folder_data: FolderCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""创建文件夹"""
# 验证父文件夹存在且属于当前用户
if folder_data.parent_id:
result = await db.execute(
select(Folder).where(
and_(Folder.id == folder_data.parent_id, Folder.user_id == current_user.id)
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="父文件夹不存在")
# 检查同名文件夹
result = await db.execute(
select(Folder).where(
and_(
Folder.user_id == current_user.id,
Folder.parent_id == folder_data.parent_id,
Folder.name == folder_data.name
)
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="同名文件夹已存在")
folder = Folder(
user_id=current_user.id,
name=folder_data.name,
parent_id=folder_data.parent_id
)
db.add(folder)
await db.commit()
await db.refresh(folder)
return folder
@router.put("/{folder_id}", response_model=FolderOut)
async def rename_folder(
folder_id: str,
folder_data: FolderUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""重命名文件夹"""
result = await db.execute(
select(Folder).where(
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="文件夹不存在")
folder.name = folder_data.name
await db.commit()
await db.refresh(folder)
return folder
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_folder(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""删除文件夹(级联删除文档)"""
from app.models.document import Document
from app.services.knowledge_service import KnowledgeService
result = await db.execute(
select(Folder).where(
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="文件夹不存在")
async def delete_recursive(fid: str):
# 删除子文件夹(先递归)
children = await db.execute(
select(Folder).where(Folder.parent_id == fid)
)
for child in children.scalars():
await delete_recursive(child.id)
# 删除文档
docs = await db.execute(
select(Document).where(Document.folder_id == fid)
)
for doc in docs.scalars():
knowledge_service = KnowledgeService(db, current_user.id)
await knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
await db.delete(doc)
# 删除文件夹本身
folder_to_delete = await db.get(Folder, fid)
if folder_to_delete:
await db.delete(folder_to_delete)
await delete_recursive(folder_id)
await db.commit()
```
- [ ] **Step 2: 提交**
---
## Task 4: 修改 Document 模型
**Files:**
- Modify: `backend/app/models/document.py:14`
- [ ] **Step 1: 添加 folder_id 外键**
```python
# backend/app/models/document.py 第14行后添加
folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # 新增
```
- [ ] **Step 2: 提交**
---
## Task 5: 修改 Document 路由和服务
**Files:**
- Modify: `backend/app/routers/document.py`
- Modify: `backend/app/services/document_service.py`
- [ ] **Step 1: 修改 Document 路由**
`routers/document.py` 中:
- GET `/api/documents` 添加 `folder_id` 可选查询参数
- POST `/api/documents` 添加 `folder_id` 表单字段
```python
# GET /api/documents 修改
@router.get("")
async def list_documents(
folder_id: Optional[str] = None, # 新增
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
query = select(Document).where(Document.user_id == current_user.id)
if folder_id:
query = query.where(Document.folder_id == folder_id)
# ... 其余不变
# POST /api/documents 修改
@router.post("")
async def upload_document(
file: UploadFile = File(...),
folder_id: Optional[str] = Form(None), # 新增
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# ... 文件处理逻辑 ...
doc = await doc_svc.upload_document(
user_id=current_user.id,
file=file,
folder_id=folder_id # 传递 folder_id
)
```
- [ ] **Step 2: 修改 DocumentService.upload_document**
```python
# backend/app/services/document_service.py
async def upload_document(
self,
user_id: str,
file: UploadFile,
folder_id: str | None = None, # 新增
) -> Document:
# ... 文件保存逻辑 ...
# 获取文件夹路径(用于 ChromaDB metadata
folder_path = None
if folder_id:
folder_path = await self._get_folder_path(folder_id)
# 创建文档记录
doc = Document(
user_id=user_id,
title=filename.rsplit('.', 1)[0],
filename=filename,
file_type=file_type,
file_size=file_size,
file_path=file_path,
folder_id=folder_id, # 新增
)
# ... 其余逻辑 ...
return doc
async def _get_folder_path(self, folder_id: str) -> str | None:
"""获取文件夹的完整路径"""
folders = await self.db.execute(
select(Folder).where(Folder.user_id == self.user_id)
)
folder_map = {f.id: f for f in folders.scalars().all()}
path_parts = []
current_id = folder_id
while current_id:
folder = folder_map.get(current_id)
if not folder:
break
path_parts.insert(0, folder.name)
current_id = folder.parent_id
return "/" + "/".join(path_parts) if path_parts else None
```
- [ ] **Step 2: 提交**
---
## Task 6: 修改 Knowledge Service
**Files:**
- Modify: `backend/app/services/knowledge_service.py`
- [ ] **Step 1: retrieve 方法添加 folder_id 参数**
```python
# backend/app/services/knowledge_service.py
# 在 index_document 方法中添加 folder_path 到 metadata
async def index_document(self, document_id: str, user_id: str, folder_path: str | None = None):
"""将文档 chunks 向量化存入 ChromaDB"""
# ... 现有代码 ...
metadatas = [
{
"document_id": doc.id,
"document_title": doc.title,
"chunk_index": chunk.chunk_index,
"file_type": doc.file_type,
"folder_path": folder_path or "", # 新增
}
for chunk in chunks
]
# ... 其余不变
async def retrieve(
self,
query: str,
user_id: str,
folder_id: str | None = None, # 新增
top_k: int = 5,
use_rerank: bool = True,
) -> list[SearchResult]:
"""混合检索 + Rerank支持按文件夹过滤"""
collection = self.get_collection(user_id)
# 构建过滤条件
where = None
if folder_id:
folder_path = await self._get_folder_path(folder_id)
if folder_path:
where = {"folder_path": {"$starts_with": folder_path}}
try:
results = collection.query(
query_texts=[query],
n_results=top_k * 3,
where=where,
include=["documents", "metadatas", "distances"],
)
except Exception:
return []
# ... 其余不变
async def _get_folder_path(self, folder_id: str) -> str | None:
"""获取文件夹的完整路径"""
result = await self.db.execute(
select(Folder).where(Folder.id == folder_id)
)
folder = result.scalar_one_or_none()
if not folder:
return None
path_parts = [folder.name]
current_parent_id = folder.parent_id
while current_parent_id:
parent_result = await self.db.execute(
select(Folder).where(Folder.id == current_parent_id)
)
parent = parent_result.scalar_one_or_none()
if not parent:
break
path_parts.insert(0, parent.name)
current_parent_id = parent.parent_id
return "/" + "/".join(path_parts)
```
- [ ] **Step 2: 提交**
---
## Task 7: 数据库迁移
**Files:**
- 数据库操作: 添加 folders 表,添加 documents.folder_id 列
- [ ] **Step 1: 执行 SQL 迁移**
```python
# 迁移脚本
import asyncio
from app.database import engine
from sqlalchemy import text
async def migrate():
async with engine.begin() as conn:
# 创建 folders 表
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS folders (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id VARCHAR(36),
user_id VARCHAR(36) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (parent_id) REFERENCES folders(id),
FOREIGN KEY (user_id) REFERENCES users(id)
)
"""))
# 添加 documents.folder_id 列
try:
await conn.execute(text("ALTER TABLE documents ADD COLUMN folder_id VARCHAR(36)"))
except Exception:
pass # 列已存在
asyncio.run(migrate())
```
- [ ] **Step 2: 提交**
---
## Task 8: 前端 - 创建 Folder API
**Files:**
- Create: `frontend/src/api/folder.ts`
- [ ] **Step 1: 创建文件夹 API 客户端**
```typescript
// frontend/src/api/folder.ts
import api from './index'
export interface FolderCreate {
name: string
parent_id?: string | null
}
export interface FolderUpdate {
name: string
}
export interface FolderItem {
id: string
name: string
parent_id: string | null
created_at: string
updated_at: string
}
export interface FolderTree {
id: string
name: string
parent_id: string | null
children: FolderTree[]
}
export const folderApi = {
// 获取文件夹树
getTree() {
return api.get<FolderTree[]>('/api/folders')
},
// 创建文件夹
create(data: FolderCreate) {
return api.post('/api/folders', data)
},
// 重命名文件夹
rename(id: string, data: FolderUpdate) {
return api.put(`/api/folders/${id}`, data)
},
// 删除文件夹
delete(id: string) {
return api.delete(`/api/folders/${id}`)
},
}
```
- [ ] **Step 2: 提交**
---
## Task 9: 前端 - 修改 Document API
**Files:**
- Modify: `frontend/src/api/document.ts`
- [ ] **Step 1: 添加 folder_id 到 Document 类型**
```typescript
// frontend/src/api/document.ts
export interface Document {
id: string
title: string
filename: string
file_type: string
file_size: number
file_path: string
summary?: string
chunk_count: number
is_indexed: boolean
folder_id?: string | null // 新增
}
```
- [ ] **Step 2: 提交**
---
## Task 10: 前端 - 创建 FolderTree 组件
**Files:**
- Create: `frontend/src/components/FolderTree.vue`
- [ ] **Step 1: 创建递归文件夹树组件**
```vue
<!-- frontend/src/components/FolderTree.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { folderApi, type FolderTree } from '@/api/folder'
import { Folder, FolderOpen, ChevronRight, MoreVertical, Plus, Edit2, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
folders: FolderTree[]
selectedId?: string | null
onSelect: (folder: FolderTree) => void
onCreate: (parentId: string | null) => void
onRename: (folder: FolderTree) => void
onDelete: (folder: FolderTree) => void
}>()
const expandedIds = ref<Set<string>>(new Set())
function toggleExpand(id: string) {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id)
} else {
expandedIds.value.add(id)
}
}
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
e.preventDefault()
// 显示右键菜单
}
</script>
<template>
<div class="folder-tree">
<div
v-for="folder in folders"
:key="folder.id"
class="folder-item"
>
<div
class="folder-row"
:class="{ selected: folder.id === selectedId }"
@click="props.onSelect(folder)"
@contextmenu="handleContextMenu($event, folder)"
>
<!-- 展开/折叠箭头 -->
<button
v-if="folder.children?.length"
class="expand-btn"
@click.stop="toggleExpand(folder.id)"
>
<ChevronRight
:size="12"
:class="{ rotated: expandedIds.has(folder.id) }"
/>
</button>
<span v-else class="expand-placeholder"></span>
<!-- 文件夹图标 -->
<FolderOpen v-if="expandedIds.has(folder.id)" :size="14" class="folder-icon" />
<Folder v-else :size="14" class="folder-icon" />
<!-- 文件夹名称 -->
<span class="folder-name">{{ folder.name }}</span>
<!-- 操作按钮 -->
<div class="folder-actions">
<button @click.stop="props.onCreate(folder.id)" title="添加子文件夹">
<Plus :size="12" />
</button>
<button @click.stop="props.onRename(folder)" title="重命名">
<Edit2 :size="12" />
</button>
<button @click.stop="props.onDelete(folder)" title="删除">
<Trash2 :size="12" />
</button>
</div>
</div>
<!-- 子文件夹递归 -->
<div
v-if="folder.children?.length && expandedIds.has(folder.id)"
class="folder-children"
>
<FolderTree
:folders="folder.children"
:selected-id="selectedId"
:on-select="onSelect"
:on-create="onCreate"
:on-rename="onRename"
:on-delete="onDelete"
/>
</div>
</div>
</div>
</template>
<style scoped>
/* sci-fi 风格 */
.folder-tree {
font-family: var(--font-mono);
font-size: 12px;
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.folder-row:hover {
background: rgba(0, 245, 212, 0.04);
}
.folder-row.selected {
background: var(--accent-cyan-dim);
border: 1px solid rgba(0, 245, 212, 0.2);
}
.expand-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-dim);
display: flex;
align-items: center;
}
.expand-placeholder {
width: 12px;
}
.folder-icon {
color: var(--accent-amber);
flex-shrink: 0;
}
.folder-name {
flex: 1;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-actions {
display: none;
gap: 2px;
}
.folder-row:hover .folder-actions {
display: flex;
}
.folder-actions button {
background: none;
border: none;
padding: 2px;
cursor: pointer;
color: var(--text-dim);
border-radius: 3px;
transition: all var(--transition-fast);
}
.folder-actions button:hover {
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.1);
}
.folder-children {
padding-left: 16px;
}
</style>
```
- [ ] **Step 2: 提交**
---
## Task 11: 前端 - 改造 KnowledgeView
**Files:**
- Modify: `frontend/src/views/KnowledgeView.vue`
- [ ] **Step 1: 添加文件夹侧边栏和交互逻辑**
主要改动:
- 导入 FolderTree 组件
- 添加文件夹状态和加载逻辑
- 修改上传逻辑(需先选择文件夹)
- 添加新建/重命名/删除文件夹的弹窗
```vue
<!-- KnowledgeView.vue 核心逻辑改动 -->
<script setup lang="ts">
// ... 现有代码 ...
// 新增
import { folderApi, type FolderTree, type FolderCreate } from '@/api/folder'
import FolderTreeComponent from '@/components/FolderTree.vue'
import { Plus, Edit2, Trash2 } from 'lucide-vue-next'
// 状态
const folders = ref<FolderTree[]>([])
const selectedFolderId = ref<string | null>(null)
// 加载文件夹树
async function loadFolders() {
const res = await folderApi.getTree()
folders.value = res.data
}
// 选择文件夹
function onSelectFolder(folder: FolderTree) {
selectedFolderId.value = folder.id
loadDocumentsByFolder(folder.id)
}
// 加载指定文件夹的文档
async function loadDocumentsByFolder(folderId: string) {
const res = await documentApi.list(folderId)
documents.value = res.data
}
// 新建文件夹弹窗
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
const newFolderParentId = ref<string | null>(null)
function openNewFolderDialog(parentId: string | null = null) {
newFolderParentId.value = parentId
newFolderName.value = ''
showNewFolderDialog.value = true
}
async function createFolder() {
await folderApi.create({
name: newFolderName.value,
parent_id: newFolderParentId.value
})
await loadFolders()
showNewFolderDialog.value = false
}
// 重命名/删除类似...
</script>
<template>
<div class="knowledge-view">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<Database :size="20" />
<h1>KNOWLEDGE BASE</h1>
</div>
<div class="header-actions">
<button class="btn" @click="openNewFolderDialog(null)">
<FolderPlus :size="14" /> 新建文件夹
</button>
<button class="btn primary" @click="triggerUpload">
<Upload :size="14" /> 上传文档
</button>
</div>
</div>
<!-- 主布局侧边栏 + 内容 -->
<div class="main-layout">
<!-- 左侧文件夹树 -->
<aside class="folder-sidebar">
<div class="sidebar-header">
<Folder :size="14" />
<span>文件夹</span>
<button class="add-btn" @click="openNewFolderDialog(null)">
<Plus :size="12" />
</button>
</div>
<div class="folder-list">
<FolderTreeComponent
:folders="folders"
:selected-id="selectedFolderId"
:on-select="onSelectFolder"
:on-create="openNewFolderDialog"
:on-rename="openRenameDialog"
:on-delete="openDeleteDialog"
/>
</div>
</aside>
<!-- 右侧内容区 -->
<main class="content-area">
<!-- 搜索栏 -->
<div class="search-panel">...</div>
<!-- 上传区 -->
<div class="upload-zone">...</div>
<!-- 文档列表 -->
<div class="docs-section">...</div>
</main>
</div>
<!-- 新建文件夹弹窗 -->
<div v-if="showNewFolderDialog" class="dialog-overlay">
<div class="dialog">
<h3>新建文件夹</h3>
<input v-model="newFolderName" placeholder="文件夹名称" @keyup.enter="createFolder" />
<div class="dialog-actions">
<button @click="showNewFolderDialog = false">取消</button>
<button class="primary" @click="createFolder">创建</button>
</div>
</div>
</div>
</div>
</template>
```
- [ ] **Step 2: 提交**
---
## Task 12: 集成测试
- [ ] **Step 1: 测试文件夹 CRUD**
- [ ] **Step 2: 测试级联删除**
- [ ] **Step 3: 测试上传文档到指定文件夹**
- [ ] **Step 4: 测试按文件夹搜索**
---
## 总结
共 12 个 Task分 4 个 Phase
- **Phase 1**: 数据层 (Task 1-4)
- **Phase 2**: 后端 API (Task 5-7)
- **Phase 3**: 前端 (Task 8-11)
- **Phase 4**: 测试 (Task 12)