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,347 @@
# Jarvis 个人 AI 助理 — Phase 1 实现计划
> 生成日期2026-03-20
> 目标:完成 Jarvis 核心功能的 MVP 版本
---
## 技术栈确认
| 组件 | 技术选型 |
|------|---------|
| **后端框架** | FastAPI (Python 3.12+) |
| **Agent 框架** | LangGraph多 Agent 编排、工具调用、状态机) |
| **LLM 适配器** | LangChain Claude / OpenAI / Ollama可切换 |
| **知识库框架** | LlamaIndexNode 关系索引、语义检索) |
| **向量数据库** | ChromaDB |
| **关系数据库** | SQLite + SQLAlchemy |
| **前端框架** | Vue 3 + TypeScript + Vite |
| **移动端** | Kotlin (Android) |
| **定时任务** | APScheduler |
| **部署** | DockerNAS 本地运行) |
---
## 目录结构
```
MyAgents/
├── backend/ # 后端项目
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py # FastAPI 入口
│ │ ├── config.py # 配置管理
│ │ ├── database.py # SQLAlchemy 数据库连接
│ │ ├── models/ # 数据库模型
│ │ │ ├── __init__.py
│ │ │ ├── user.py
│ │ │ ├── document.py
│ │ │ ├── task.py
│ │ │ ├── forum.py
│ │ │ ├── agent.py
│ │ │ ├── conversation.py
│ │ │ └── knowledge_graph.py
│ │ ├── schemas/ # Pydantic 请求/响应模型
│ │ │ ├── __init__.py
│ │ │ ├── auth.py
│ │ │ ├── conversation.py
│ │ │ ├── document.py
│ │ │ ├── task.py
│ │ │ ├── forum.py
│ │ │ └── graph.py
│ │ ├── routers/ # API 路由
│ │ │ ├── __init__.py
│ │ │ ├── auth.py
│ │ │ ├── conversation.py
│ │ │ ├── document.py
│ │ │ ├── task.py
│ │ │ ├── forum.py
│ │ │ └── graph.py
│ │ ├── agents/ # LangGraph Agent 系统
│ │ │ ├── __init__.py
│ │ │ ├── graph.py # 主 Agent 图定义
│ │ │ ├── nodes/ # Agent 节点
│ │ │ │ ├── __init__.py
│ │ │ │ ├── master.py # 主调度 Agent
│ │ │ │ ├── planner.py # 规划 Agent
│ │ │ │ ├── executor.py # 执行 Agent
│ │ │ │ ├── librarian.py # 知识管理员 Agent
│ │ │ │ └── analyst.py # 分析师 Agent
│ │ │ ├── tools/ # Agent 工具集
│ │ │ │ ├── __init__.py
│ │ │ │ ├── search.py # 知识库检索工具
│ │ │ │ ├── task.py # 任务操作工具
│ │ │ │ ├── forum.py # 论坛操作工具
│ │ │ │ └── graph.py # 图谱操作工具
│ │ │ └── prompts/ # Agent 提示词
│ │ │ ├── __init__.py
│ │ │ ├── master_prompt.py
│ │ │ └── sub_agent_prompts.py
│ │ ├── services/ # 业务逻辑服务
│ │ │ ├── __init__.py
│ │ │ ├── llm_service.py # LLM 调用服务
│ │ │ ├── knowledge_service.py # 知识库服务
│ │ │ ├── graph_service.py # 知识图谱服务
│ │ │ ├── scheduler_service.py # 定时任务服务
│ │ │ └── agent_service.py # Agent 调用服务
│ │ ├── knowledge/ # 知识库核心
│ │ │ ├── __init__.py
│ │ │ ├── indexer.py # LlamaIndex 索引器
│ │ │ ├── chunker.py # 文档分块策略
│ │ │ └── retriever.py # 检索器
│ │ └── utils/ # 工具函数
│ │ ├── __init__.py
│ │ └── security.py
│ ├── tests/ # 测试
│ │ ├── __init__.py
│ │ ├── test_agents.py
│ │ ├── test_knowledge.py
│ │ └── test_api.py
│ ├── pyproject.toml
│ ├── uv.lock
│ └── Dockerfile
├── frontend/ # 前端项目
│ ├── src/
│ │ ├── App.vue
│ │ ├── main.ts
│ │ ├── api/ # API 调用
│ │ │ ├── index.ts
│ │ │ ├── conversation.ts
│ │ │ ├── document.ts
│ │ │ ├── task.ts
│ │ │ ├── forum.ts
│ │ │ └── graph.ts
│ │ ├── components/ # 通用组件
│ │ ├── views/ # 页面视图
│ │ │ ├── ChatView.vue # 主对话页
│ │ │ ├── KnowledgeView.vue # 知识库页
│ │ │ ├── GraphView.vue # 知识图谱页
│ │ │ ├── ForumView.vue # 论坛页
│ │ │ ├── KanbanView.vue # 看板页
│ │ │ └── LoginView.vue # 登录页
│ │ ├── stores/ # Pinia 状态管理
│ │ ├── router/ # Vue Router
│ │ ├── types/ # TypeScript 类型
│ │ └── styles/ # 全局样式
│ ├── public/
│ ├── package.json
│ └── vite.config.ts
├── android/ # Android 项目(后续)
├── docker-compose.yml
├── .env.example
└── README.md
```
---
## 开发阶段
### Phase 1 — 核心骨架(第 1-2 周)
**目标**:跑通最基础的服务,能对话、能上传文档、能检索
1. **搭建后端项目** — FastAPI + 项目结构 + 依赖安装
2. **搭建前端项目** — Vue 3 + Vite + TypeScript + 基础路由
3. **实现 LLM 适配器** — LangChain Claude/OpenAI/Ollama 统一接口
4. **实现简单对话** — 单 Agent + WebSocket 流式输出
5. **实现知识库上传** — LlamaIndex + ChromaDB + 文档分块
6. **实现基础检索** — 向量检索 + 返回结果
### Phase 2 — 多 Agent 系统(第 3-4 周)
**目标**:多 Agent 协作跑起来
1. **实现主 Agent** — LangGraph 状态机 + 工具注册
2. **实现子 Agent** — 规划、执行、知识管理、分析师 4 个角色
3. **实现工具集** — 知识检索、任务操作、论坛操作工具
4. **Agent 通信** — 协作式 + 主 Agent 协调模式
### Phase 3 — 知识图谱(第 5-6 周)
**目标**:文档知识能沉淀到图谱中
1. **实体识别** — LLM 从文档 Node 中提取实体
2. **关系抽取** — LLM 抽取实体间关系
3. **图谱存储** — nodes + edges 存入 SQLite
4. **图谱可视化** — 前端 D3.js / ECharts 渲染
### Phase 4 — 论坛 + 看板(第 7-8 周)
**目标**论坛发帖、AI 扫描执行、看板任务管理
1. **论坛 CRUD** — 帖子发布、回复、列表
2. **AI 扫描引擎** — 定时扫描论坛、识别可执行指令
3. **看板 CRUD** — 任务卡片、优先级、状态
4. **AI 每日规划** — 凌晨分析完成情况、生成次日建议
### Phase 5 — 前端完整 UI第 9-10 周)
**目标**:所有功能页面完成,科幻风格 UI
1. **主对话界面** — 流式输出、Agent 角色切换
2. **知识库界面** — 文件上传、检索、结果展示
3. **图谱可视化** — 可交互的节点关系图
4. **论坛界面** — 发帖、列表、AI 执行标记
5. **看板界面** — 拖拽卡片、状态流转、AI 建议
### Phase 6 — Android App第 11-12 周)
**目标**:移动端能对话、能看看板
1. **Android 项目搭建** — Kotlin + Jetpack Compose
2. **对话界面** — WebSocket 连接后端、流式对话
3. **看板视图** — 任务列表、状态切换
4. **基础设置** — 服务器地址配置
### Phase 7 — 部署 + 优化(第 13-14 周)
**目标**:部署到 NAS稳定运行
1. **Docker 打包** — 后端 + 前端镜像
2. **NAS 部署** — Docker Compose 一键启动
3. **性能优化** — 缓存、异步、数据库索引
4. **安全加固** — JWT、API 限流、数据加密
---
## Phase 1 详细任务
### 1.1 后端项目初始化
```
backend/
├── pyproject.toml
│ ├── fastapi>=0.115.0
│ ├── uvicorn[standard]>=0.30.0
│ ├── langgraph>=0.2.0
│ ├── langchain-anthropic>=0.3.0
│ ├── langchain-openai>=0.2.0
│ ├── llama-index>=0.12.0
│ ├── llama-index-vector-stores-chroma>=0.3.0
│ ├── chromadb>=0.5.0
│ ├── sqlalchemy>=2.0.0
│ ├── aiosqlite>=0.20.0
│ ├── pydantic>=2.0.0
│ ├── pydantic-settings>=2.0.0
│ ├── python-jose[cryptography]>=3.3.0
│ ├── passlib[bcrypt]>=1.7.4
│ ├── APScheduler>=3.10.0
│ ├── python-multipart>=0.0.12
│ ├── websockets>=12.0
│ └── aiofiles>=24.0.0
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── config.py
│ └── database.py
└── Dockerfile
```
### 1.2 前端项目初始化
```
frontend/
├── Vite + Vue 3 + TypeScript
├── Pinia (状态管理)
├── Vue Router 4
├── Axios (HTTP 客户端)
├── VueUse (工具函数)
├── TailwindCSS (样式)
└── Lucide Vue (图标)
```
### 1.3 LLM 适配器接口
```python
# backend/app/services/llm_service.py
from langchain_anthropic import ChatAnthropic
from langchain_openai import ChatOpenAI
from langchain_ollama import ChatOllama
from abc import ABC, abstractmethod
class LLMAdapter(ABC):
@abstractmethod
async def invoke(self, messages: list[dict]) -> str:
...
@abstractmethod
async def stream(self, messages: list[dict]):
...
class ClaudeAdapter(LLMAdapter):
def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"):
self.llm = ChatAnthropic(api_key=api_key, model=model)
class OpenAIAdapter(LLMAdapter):
def __init__(self, api_key: str, model: str = "gpt-4o"):
self.llm = ChatOpenAI(api_key=api_key, model=model)
class OllamaAdapter(LLMAdapter):
def __init__(self, base_url: str = "http://localhost:11434", model: str = "llama3"):
self.llm = ChatOllama(base_url=base_url, model=model)
# 工厂函数,根据配置返回对应适配器
def get_llm_adapter(provider: str, **kwargs) -> LLMAdapter:
adapters = {
"claude": ClaudeAdapter,
"openai": OpenAIAdapter,
"ollama": OllamaAdapter,
}
return adapters[provider](**kwargs)
```
### 1.4 简单对话 API
```python
# POST /api/chat
# Body: { "message": "你好 Jarvis", "conversation_id": "uuid" }
# Response: WebSocket 连接,流式返回
@app.websocket("/ws/chat/{conversation_id}")
async def websocket_chat(websocket, conversation_id: str):
async for message in websocket:
# 1. 存入历史
# 2. 调用 LangGraph Agent
# 3. 流式返回结果
yield "data: ..."
# GET /api/conversations — 对话历史列表
# POST /api/conversations — 创建新对话
# DELETE /api/conversations/{id} — 删除对话
```
### 1.5 知识库上传 API
```python
# POST /api/documents/upload
# Body: multipart/form-data, file + metadata
# 返回: document_id, chunk_count
# GET /api/documents — 文档列表
# GET /api/documents/{id} — 文档详情 + chunks
# DELETE /api/documents/{id} — 删除文档
# POST /api/documents/search
# Body: { "query": "查找...", "top_k": 5 }
# 返回: 检索结果列表
```
---
## 第一步操作
现在开始执行 Phase 1.1 — 搭建后端项目结构。
需要创建:
1. `backend/pyproject.toml`
2. `backend/app/__init__.py`
3. `backend/app/main.py`
4. `backend/app/config.py`
5. `backend/app/database.py`
6. `backend/.env.example`
是否现在开始?

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)

View File

@@ -0,0 +1,83 @@
# Agent Dashboard 页面设计规格
## 概述
为 Jarvis 系统设计一个 Agent 管理页面以全息战术投影Holographic Tactical HUD风格可视化展示 Master + 4 Sub-Agent 的组织架构,支持查看状态和配置。
## 视觉风格
- **主题**:全息战术投影(科幻指挥台)
- **背景**#03050a 深空黑 + 微弱网格线 + 全息扫描线纹理
- **节点样式**:半透明玻璃态卡片,悬浮空中,全息光晕边框
- **字体**Orbitron标题+ JetBrains Mono正文
- **配色**Cyan #00f5d4 主色Amber #f9a825 强调色Red #ff4757 危险色
## 布局结构
```
┌──────────────────────────────────────────────────────────┐
│ AGENT COMMAND CENTER [刷新] [新增] │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ │
│ │ MASTER CORE │ │
│ │ JARVIS 指挥官 │ │
│ │ [●] 状态灯 │ │
│ └─────────┬──────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ PLANNER │ │ EXECUTOR │ │LIBRARIAN │ │
│ │ [●] │ │ [●] │ │ [●] │ │
│ │ 规划者 │ │ 执行者 │ │ 知识官 │ │
│ │ 调用:12 │ │ 调用:8 │ │ 调用:5 │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ ANALYST │ │
│ │ [●] │ │
│ │ 分析师 │ │
│ │ 调用:3 │ │
│ └───────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
点击节点 → 右侧滑出配置抽屉
```
## 节点卡片字段
- 名称Orbitron
- 角色标签(中文)
- 状态灯:绿色脉冲=活跃,灰色=空闲
- 角色描述2行
- 调用次数(今日)
- 当前任务摘要
## 连接线
- 虚线连接 Master → Sub-Agent
- 任务触发时:琥珀色脉冲光点沿路径流向目标节点
## 配置面板(右侧抽屉 400px
- Agent 名称
- 角色描述
- 系统提示词textarea
- 启用/停用开关
- 保存 / 重置按钮
## 数据来源
- 固定结构:前端 `src/data/agents.ts`
- 运行时状态:`/api/agents/stats`
## API 设计
```
GET /api/agents/stats → { agent_id, call_count, current_task, status }
GET /api/agents/config/{id} → 返回单个 Agent 完整配置
PUT /api/agents/config/{id} → 更新 name/description/system_prompt/enabled
```

View File

@@ -0,0 +1,192 @@
# 沟通系统增强设计
## 1. 概述与目标
在沟通系统ChatView中增加两个功能
1. **文件上传** - 用户可在对话中上传文件AI 自动理解内容并回复
2. **表情包选择器** - 在发送按钮旁添加 emoji 选择面板
## 2. 技术方案
### 2.1 文件上传
**前端实现:**
-`ChatView.vue` 输入区域添加附件按钮Paperclip 图标)
- 使用 `<input type="file">` 触发文件选择
- 支持类型图片jpg/png/gif/webp、文档pdf/doc/docx/xls/xlsx/ppt/pptx/txt
- 文件大小限制10MB
- 上传时显示进度状态
**消息气泡展示:**
- 文件上传成功后,在对话中显示文件消息气泡
- 气泡内容:文件图标 + 文件名 + 文件大小
- 点击可下载/预览
**后端实现:**
- 复用现有 `/api/documents/upload` 接口上传文件
- 创建 KGNodeentity_type: 'document')关联到对话
- 修改 `AgentService.chat_simple()` 支持文件上下文
- AI 自动读取上传文件内容并理解
**数据流:**
```
用户选择文件 → 前端上传到 /api/documents/upload
→ 后端存储文件,创建 KGNode
→ 前端发送消息带 file_ids
→ AgentService 读取文件内容
→ AI 基于文件内容回复
```
### 2.2 表情包选择器
**前端实现:**
- 在发送按钮旁添加 Emoji 图标按钮
- 点击展开浮层面板,显示 emoji 分类网格
- 分类:😀 笑脸 | 👍 手势 | 📦 物品 | 💬 符号
- 每个分类显示常用 emoji 网格
- 点击 emoji 插入到输入框
- 点击外部关闭面板
**Emoji 数据:**
```typescript
const emojiCategories = {
smile: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌'],
gesture: ['👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🖖', '🤙', '💪', '🙏', '👏'],
object: ['📄', '📁', '🖼️', '📊', '📝', '💾', '📧', '🔗', '📌', '🔍', '💡', '⚡'],
symbol: ['✅', '❌', '⚠️', '🔥', '💯', '🎯', '⭐', '✨', '💬', '🗨️', '❤️', '🧡']
}
```
## 3. API 变更
### 3.1 修改 ChatRequest
```python
class ChatRequest(BaseModel):
message: str
conversation_id: str | None = None
agent_id: str | None = None
file_ids: list[str] = [] # 新增上传的文件ID列表
```
### 3.2 修改 Message 模型(可选扩展)
```python
class Message(BaseModel):
# 新增字段
attachments: list[dict] = [] # [{file_id, filename, file_type, file_size}]
```
### 3.3 新增文件读取接口
```
GET /api/documents/{document_id}/content
返回: 文件的文本内容(用于 AI 理解)
```
## 4. 组件变更
### 4.1 ChatView.vue 变更
**新增:**
- `fileInput` ref - 文件 input
- `showEmojiPicker` ref - emoji 面板显示状态
- `selectedFiles` ref - 已选择待上传文件
- `uploadFile()` - 上传文件方法
- `insertEmoji()` - 插入 emoji 到输入框
**修改:**
- 输入区域布局:附件按钮 | 输入框 | Emoji按钮 | 发送按钮
- `sendMessage()` - 发送前先上传文件,获取 file_ids
### 4.2 EmojiPicker 组件(新建)
```vue
<script setup lang="ts">
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
select: [emoji: string]
close: []
}>()
const categories = {
smile: { name: '😀', emojis: [...] },
gesture: { name: '👍', emojis: [...] },
object: { name: '📦', emojis: [...] },
symbol: { name: '💬', emojis: [...] }
}
</script>
```
### 4.3 FileMessage 组件(新建)
用于展示文件消息气泡:
- 文件图标(根据类型)
- 文件名(可截断)
- 文件大小
- 下载按钮
## 5. 错误处理
| 场景 | 处理 |
|------|------|
| 文件类型不支持 | 提示"不支持该文件类型" |
| 文件超过10MB | 提示"文件超过10MB限制" |
| 上传失败 | 提示"上传失败,请重试",显示重试按钮 |
| AI读取文件失败 | AI 回复"无法读取文件内容" |
| 网络断开 | 提示"网络连接断开" |
## 6. 状态定义
| 状态 | 显示 |
|------|------|
| 上传中 | 进度环 + 文件名 |
| 上传成功 | 文件气泡 |
| 上传失败 | 错误图标 + 重试按钮 |
| AI 读取中 | AI 思考状态..." |
## 7. 实现顺序
1. **Phase 1: 基础 UI**
- 添加附件按钮和 Emoji 按钮到输入区域
- Emoji 选择器组件
- 文件消息气泡组件
2. **Phase 2: 文件上传**
- 前端文件上传逻辑
- 消息带 file_ids
- 文件气泡展示
3. **Phase 3: AI 理解文件**
- 后端文件内容读取接口
- AgentService 支持文件上下文
- 测试完整流程
## 8. 文件结构
```
frontend/src/
├── views/
│ └── ChatView.vue # 修改 - 添加附件/Emoji按钮
├── components/
│ ├── chat/
│ │ ├── EmojiPicker.vue # 新建 - Emoji 选择器
│ │ └── FileMessage.vue # 新建 - 文件消息气泡
│ └── stats/ # 已存在
│ └── ...
└── 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
```

View File

@@ -0,0 +1,178 @@
# Daily Todo 功能设计文档
## 概述
每日待办Daily Todo是一个以"天"为维度的任务管理模块,与现有的看板(以项目/多天为维度)形成互补。
**核心价值:** AI 每天早上自动预生成今日待办(基于前一天未完成的看板任务 + 前一天对话记录),用户可手动增删改。
## 时区说明
- 所有日期相关字段均使用**用户本地日期**(后端统一用 `datetime.date.today()` 计算,不依赖 UTC
- `todo_date` 格式:`YYYY-MM-DD`(本地日期字符串),便于按天查询
## 数据模型
### DailyTodo 表
| 字段 | 类型 | 说明 |
|------|------|------|
| id | String(36) | 主键UUID |
| user_id | String(36) | 所属用户,索引 |
| title | String(500) | 待办标题 |
| is_completed | Boolean | 是否完成,默认 false |
| source | Enum | `ai_kanban` / `ai_chat` / `manual`,来源 |
| source_detail | String(500) | 展示用说明文本,如"看板:完成用户登录功能" |
| source_ref_id | String(36) | 来源原始ID看板TaskID或对话ConversationID可空 |
| todo_date | String(10) | 所属日期,格式 YYYY-MM-DD复合索引 (user_id, todo_date) |
| completed_at | DateTime | 完成时间,可空 |
| created_at | DateTime | 创建时间 |
| updated_at | DateTime | 更新时间 |
**索引:** `INDEX (user_id, todo_date)`,查询今日待办的主要路径
### DailyTodoHistory 归档表
归档时机:每天凌晨 1:00APScheduler 清理 7 天前的记录
| 字段 | 类型 | 说明 |
|------|------|------|
| id | String(36) | 主键UUID |
| original_id | String(36) | 原记录ID原记录归档后可能已删除 |
| user_id | String(36) | 所属用户 |
| title | String(500) | 待办标题 |
| is_completed | Boolean | 最终完成状态 |
| source | Enum | 来源 |
| source_detail | String(500) | 展示用说明文本 |
| todo_date | String(10) | 所属日期 |
| completed_at | DateTime | 完成时间 |
| created_at | DateTime | 创建时间 |
| archived_at | DateTime | 归档时间 |
**保留策略:** 归档记录保留 7 天到期自动删除APScheduler 每日清理)
## 核心功能
### F1: 今日待办列表
- 展示当天的所有待办事项
- 每条可勾选完成状态(勾选后划线 + 变灰)
- 支持新增、编辑、删除
- 按创建时间倒序排列
- 分页:每页 50 条,支持 `page` + `page_size` 参数
### F2: 历史记录
- 可查看昨天、前天等历史日期的待办
- 切换日期查看,**只读**(历史不允许修改/删除)
- 历史数据来自 `DailyTodo` 表(按 todo_date 过滤)
- 注:不从 `DailyTodoHistory` 表读取——归档表仅作备份保留
### F3: AI 自动预生成
- 触发时机:每天早上 8:00APScheduler 定时任务),也可手动触发
- 数据来源:
1. **看板任务**:前一天创建的、状态 ≠ done 的任务,取前 20 条(按 created_at 倒序)
2. **对话记录**:前一天创建的对话,取其消息内容前 2000 字发给 LLM
- AI 处理流程:
1. 查询上述数据,拼装为分析文本
2. 发送给 LLMPrompt 要求输出 JSON 数组:`[{ "title": "...", "reason": "..." }]`
3. 解析 LLM 返回,若返回为空或解析失败则跳过对话分析
4. 批量写入 DailyTodo 表source=ai_kanban / ai_chat
- **幂等处理(关键)**:使用事务 + 插入前检查,确保同一天不会重复生成
```
BEGIN TRANSACTION
IF EXISTS (SELECT 1 FROM daily_todos WHERE user_id=? AND todo_date=? AND source IN ('ai_kanban','ai_chat')):
ROLLBACK -- 已有AI生成跳过
ELSE:
INSERT ... -- 批量写入
COMMIT
```
- **容错**LLM 不可用时记录日志,跳过该部分,不阻塞整体流程
- 看板任务上限 20 条,对话分析最多提取 3 条
### F4: AI 来源说明
- 每条 AI 生成的待办,显示其来源说明
- `source=ai_kanban``source_detail` = "看板:{任务标题}"`source_ref_id` = 原始 Task ID
- `source=ai_chat``source_detail` = "对话:{reason 摘要截取前60字}"
## API 设计
### GET /api/todos
查询待办列表(支持分页)
- Query: `?date=2026-03-20&page=1&page_size=50`date 默认当天)
- Response:
```json
{
"items": [DailyTodoOut],
"total": 12,
"page": 1,
"page_size": 50
}
```
### POST /api/todos
新增待办(手动)
- Body: `{ title: string }`
- source 固定为 `manual`todo_date 为当天
### PATCH /api/todos/{id}
更新待办(完成状态 / 标题)
- Body: `{ is_completed?: boolean, title?: string }`
- 仅当日待办可修改,历史日期返回 403
### DELETE /api/todos/{id}
删除待办
- 仅当日待办可删除,历史日期返回 403
### POST /api/todos/ai-generate
手动触发 AI 预生成
- 检查今日是否已有 AI 生成记录,有则返回 200幂等不重复生成
- 无则执行 AI 分析流程,返回生成结果
### GET /api/todos/summary
获取今日待办摘要
- Response: `{ date: "2026-03-20", total: 5, completed: 2, pending: 3 }`
## 响应 Schema
### DailyTodoOut
```json
{
"id": "uuid",
"title": "完成用户登录功能",
"is_completed": false,
"source": "ai_kanban",
"source_detail": "看板:完成用户登录功能",
"todo_date": "2026-03-20",
"completed_at": null,
"created_at": "2026-03-20T08:00:00Z"
}
```
## 定时任务
| 任务 | 时间 | 说明 |
|------|------|------|
| AI预生成 | 每天 08:00 | 为所有活跃用户执行 AI 预生成 |
| 历史归档清理 | 每天 01:00 | 删除 7 天前已归档的 DailyTodo 记录 |
## 前端页面
### TodoView.vue
- 路径:`/todo`
- 布局:顶部日期导航 + 下方待办列表
- 日期导航:今天、昨天、前天快捷按钮 + 日期选择器
- 今日视图:输入框新增 + 列表 + "AI 规划今日"按钮
- 历史视图:只读列表,无新增/删除按钮,灰色禁用样式
- 交互细节:
- 勾选完成Motion 动画划线效果
- 加载状态:骨架屏
- 空状态:终端风格空提示
- 风格sci-fi 全息终端cyan (#00f5d4) + #03050a与 AgentView 一致
### 侧边栏
- 新增菜单项:`{ name: '待办', path: '/todo', icon: CheckSquare }`
## 技术依赖
- 后端FastAPI + SQLAlchemy + APScheduler + LLM Service
- 前端Vue 3 Composition API + 复用 api/index 的 axios 实例
- 数据库:新表 DailyTodo + DailyTodoHistory迁移 Alembic 或手动 CREATE TABLE

View File

@@ -0,0 +1,602 @@
# Jarvis 个人 AI 助理 — 设计规格书
> 版本v1.0
> 日期2026-03-20
> 作者Jarvis 设计团队
---
## 1. 项目概述
### 1.1 项目目标
构建一个拟人化的个人 AI 助理系统,代号 **Jarvis**。核心目标是打造一个真正"懂你"的智能体 —— 理解你的知识体系、工作安排和个人偏好,而不仅仅是关键词匹配回答问题。
### 1.2 核心价值
- **知识回溯能力** — 基于 LlamaIndex Node 关系 + 知识图谱双层架构,确保 AI 真正理解你的知识和工作的内在联系
- **拟人化协作** — 多 Agent 角色协同,每个角色有独立职责,像真实团队成员一样交流
- **全端覆盖** — Web + Android 双端,随时随地与 Jarvis 对话
- **本地部署** — 所有数据存储在 NAS数据完全自主可控
---
## 2. 技术栈
| 层级 | 技术选型 | 说明 |
|------|---------|------|
| **Web 前端** | Vue 3 + TypeScript | Composition API响应式 UI |
| **移动端** | Kotlin (Android) | Jetpack Compose轻量连接器 |
| **后端框架** | FastAPI (Python 3.12+) | 高性能 ASGI支持 async |
| **Agent 框架** | LangGraph | 多 Agent 编排、工具调用、状态机流转 |
| **LLM 适配器** | LangChain Claude / OpenAI / Ollama | 可切换,不影响上层逻辑 |
| **知识库框架** | LlamaIndex | Node 关系索引、语义检索 |
| **向量数据库** | ChromaDB | 轻量级向量存储 |
| **关系数据库** | SQLite | 轻量数据持久化 |
| **定时任务** | APScheduler | 定时任务调度 |
| **部署环境** | NAS (本地) | Docker 容器化部署 |
---
## 3. 系统架构
### 3.1 整体架构图
```
┌─────────────────────────────────────────────────────┐
│ 用户端 │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Web 前端 │ │ Android App │ │
│ │ (Vue 3 + TS) │ │ (Kotlin) │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
└───────────┼────────────────────────┼─────────────────┘
│ │
│ HTTP / WebSocket │
└────────┬────────────────┘
┌────────────────────▼─────────────────────────────────┐
│ FastAPI 后端服务 │
│ (NAS Docker 容器) │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ 多 Agent 调度系统 │ │
│ │ ┌─────────┐ │ │
│ │ │ 主Agent │ ◄── 协调者,统一入口 │ │
│ │ │(调度员) │ │ │
│ │ └────┬────┘ │ │
│ │ ├──► 规划Agent ──► 任务拆解、计划制定 │ │
│ │ ├──► 执行Agent ──► 工具调用、任务执行 │ │
│ │ ├──► 知识管理员 ──► 知识库管理、图谱更新 │ │
│ │ └──► 分析师Agent ──► 数据分析、报告生成 │ │
│ │ └──► [可扩展] ────► 新角色注册机制 │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │
│ │ LLM 适配器 │ │ 定时任务 │ │ 论坛扫描 │ │
│ │ LangChain │ │ 引擎 │ │ 引擎 │ │
│ │ (可切换) │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └──────────────┘ │
└──────────────────────────┬────────────────────────────┘
┌─────────────────────┼─────────────────────┐
│ │ │
┌────▼────┐ ┌─────────────▼────┐ ┌──────────▼────────┐
│ ChromaDB│ │ SQLite │ │ 文件存储 │
│向量数据库│ │ (关系数据) │ │ (NAS 共享目录) │
└─────────┘ └───────────────────┘ └────────────────────┘
```
### 3.2 通信模式
- **协作式 + 主 Agent 协调**
- 主 Agent 作为统一入口,接收用户请求后分发到子 Agent
- 子 Agent 完成任务后汇总结果给主 Agent
- 子 Agent 之间可通过主 Agent 传递信息
- 支持新增 Agent 注册到系统中
---
## 4. 核心功能模块
### 4.1 多 Agent 调度系统
#### Agent 角色定义
| Agent | 职责 | 核心能力 |
|-------|------|---------|
| **主Agent (Jarvis)** | 协调调度、对话入口 | 意图识别、任务分发、结果汇总 |
| **规划Agent** | 制定每日计划 | 任务拆解、优先级排序、时间规划 |
| **执行Agent** | 执行具体任务 | 工具调用、进度追踪、结果反馈 |
| **知识管理员** | 管理知识库和图谱 | 文档索引、实体提取、图谱更新 |
| **分析师Agent** | 分析工作数据 | 数据统计、趋势分析、报告生成 |
#### Agent 扩展机制
- 通过配置文件或 API 注册新 Agent
- 每个 Agent 有独立的 system prompt 和工具集
- 新增 Agent 自动出现在对话上下文中
### 4.2 知识库系统
#### 文档处理流程
```
用户上传文件
文件解析
├── Markdown → 直接读取
├── PDF → PDF 解析PyMuPDF
├── DOCX → python-docx
└── TXT → 直接读取
LlamaIndex Node 构建
├── 按标题层级切分Header-based Chunking
├── 保留 Node 关系链表PARENT, PREVIOUS, NEXT, SOURCE
└── 每个 Node 包含 metadata标题、章节、页码
向量存储 → ChromaDB
知识图谱构建
├── LLM 实体识别(从 Node 内容中提取)
├── LLM 关系抽取(实体之间的关系)
└── 存入 SQLitenodes + edges 表)
```
#### 检索流程Small-to-Big 策略)
```
用户提问
ChromaDB 向量检索
├── 用小 Chunk 精确匹配
└── 返回多个相关 Node
上下文回溯
├── 顺着 Node 关系找到完整章节(父 Node
└── 附加上下文给 LLM
LLM 生成回答
```
### 4.3 知识图谱系统
#### 图谱数据结构
```sql
-- 知识图谱节点表
knowledge_graph_nodes (
id TEXT PRIMARY KEY,
user_id TEXT, -- 用户隔离(支持多用户)
entity_type TEXT, -- 实体类型PERSON / EVENT / CONCEPT / OBJECT
entity_name TEXT, -- 实体名称
description TEXT, -- 实体描述
source_doc_id TEXT, -- 来源文档
source_node_id TEXT, -- 来源 Node
importance REAL, -- 重要程度 (0-1)
created_at TIMESTAMP,
updated_at TIMESTAMP
)
-- 知识图谱边表
knowledge_graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT, -- 用户隔离
source_node_id TEXT,
target_node_id TEXT,
relation_type TEXT, -- 关系类型:包含 / 依赖 / 相关 / 导致 / 属于
weight REAL, -- 关系权重 (0-1)
created_at TIMESTAMP,
FOREIGN KEY (source_node_id) REFERENCES knowledge_graph_nodes(id),
FOREIGN KEY (target_node_id) REFERENCES knowledge_graph_nodes(id)
)
```
#### 图谱更新机制
- **事件驱动**:文档上传/任务变更时实时触发
- **定时同步**:每日凌晨增量扫描,防止遗漏
- **手动触发**:用户可主动要求重建图谱
- **增量检测**:基于文件 mtime + 内容 hash 判断文档是否变化
#### 数据模型
```sql
-- 文档表
documents (
id TEXT PRIMARY KEY,
user_id TEXT, -- 用户隔离
filename TEXT,
file_type TEXT, -- pdf / markdown / docx / txt
file_path TEXT, -- NAS 存储路径
file_hash TEXT, -- 内容 hash用于增量检测
summary TEXT, -- AI 生成的文档摘要
file_size INTEGER,
created_at TIMESTAMP,
updated_at TIMESTAMP
)
-- 文档分块表LlamaIndex Node 映射)
document_chunks (
id TEXT PRIMARY KEY,
user_id TEXT, -- 用户隔离
document_id TEXT,
chunk_index INTEGER, -- 在文档中的顺序
content TEXT, -- 原始文本内容
metadata JSON, -- LlamaIndex Node metadata包含 title、chapter、relationships 等)
embedding_id TEXT, -- ChromaDB 中的向量 ID
created_at TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id)
)
```
#### 图谱可视化
- 前端 Web 端展示交互式知识图谱
- 节点可点击查看详情
- 支持按类型筛选、按时间筛选
- 支持搜索实体名称
### 4.4 论坛系统
#### 功能设计
- **发布内容** — 你在论坛发布想法、指令、问题
- **AI 扫描** — Jarvis 定时扫描论坛内容
- **任务识别** — 识别可执行的任务转为看板任务
- **互动回应** — AI 在帖子下回复,像团队成员讨论
#### 数据模型
```sql
forum_posts (
id TEXT PRIMARY KEY,
user_id TEXT, -- 发帖用户
title TEXT,
content TEXT,
parent_id TEXT, -- 回复的帖子 ID自关联支持嵌套回复
status TEXT, -- pending / processing / completed
created_at TIMESTAMP,
updated_at TIMESTAMP
)
```
### 4.5 看板系统
#### 功能设计
- **任务管理** — 创建、编辑、删除任务
- **状态流转** — 待办 / 进行中 / 已完成 / 已取消
- **优先级** — P0 / P1 / P2 / P3
- **AI 凌晨分析** — 每日凌晨分析完成情况,规划次日任务
- **AI 建议** — 根据你的工作模式给出优先级建议
#### 数据模型
```sql
tasks (
id TEXT PRIMARY KEY,
user_id TEXT, -- 用户隔离
title TEXT,
description TEXT,
priority TEXT, -- P0 / P1 / P2 / P3
status TEXT, -- todo / in_progress / done / cancelled
deadline TIMESTAMP,
created_at TIMESTAMP,
updated_at TIMESTAMP,
completed_at TIMESTAMP
)
task_history (
id TEXT PRIMARY KEY,
task_id TEXT,
action TEXT, -- created / updated / completed / cancelled
old_value TEXT,
new_value TEXT,
timestamp TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES tasks(id)
)
```
### 4.6 Markdown 编辑器
#### 功能设计
- 浏览器端在线编辑 Markdown
- 支持实时预览
- AI 辅助功能:
- AI 续写
- AI 润色
- AI 总结
- 自动保存到知识库
- 支持创建新文档和编辑已有文档
### 4.7 定时任务引擎
#### 内置定时任务
| 任务 | 触发时间 | 功能 |
|------|---------|------|
| 论坛扫描 | 可配置(默认每小时) | 扫描新帖子,识别可执行任务 |
| 图谱增量同步 | 每日凌晨 2:00 | 扫描文档变化,更新知识图谱 |
| 每日规划 | 每日早上 8:00 | 分析昨日任务完成情况,规划当日 |
| 知识摘要 | 每周一凌晨 | 生成上周工作摘要 |
---
## 5. 数据库设计
### 5.1 ER 图
```
users
documents ──► document_chunks ──► embeddings (ChromaDB)
knowledge_graph_nodes ◄──► knowledge_graph_edges
tasks ◄─── task_history
forum_posts (自关联: parent_id ──► forum_posts.id)
conversations ──► messages
```
### 5.2 核心表结构
| 表名 | 说明 |
|------|------|
| `users` | 用户信息 |
| `documents` | 上传的文档元数据 |
| `document_chunks` | LlamaIndex Node 映射(保留关系) |
| `knowledge_graph_nodes` | 知识图谱节点 |
| `knowledge_graph_edges` | 知识图谱边 |
| `tasks` | 看板任务 |
| `task_history` | 任务变更历史 |
| `forum_posts` | 论坛帖子(含回复,通过 parent_id 自关联) |
| `conversations` | 主对话会话 |
| `messages` | 对话消息 |
| `knowledge_summaries` | 历史对话摘要 |
#### 对话数据模型
```sql
conversations (
id TEXT PRIMARY KEY,
user_id TEXT, -- 用户隔离
title TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP
)
messages (
id TEXT PRIMARY KEY,
conversation_id TEXT,
role TEXT, -- user / assistant
content TEXT,
model TEXT, -- 使用的模型
created_at TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
)
knowledge_summaries (
id TEXT PRIMARY KEY,
user_id TEXT,
period TEXT, -- daily / weekly / monthly
period_start DATE,
period_end DATE,
summary TEXT, -- 摘要内容
created_at TIMESTAMP
)
```
---
## 6. API 设计
### 6.1 主要 API 端点
> **通用规则**:所有列表接口支持分页参数 `page`(页码,默认 1和 `page_size`(每页数量,默认 20。返回格式统一为 `{ data: [...], total: N, page: X, page_size: Y }`。
#### 认证接口
- `POST /api/auth/register` — 用户注册
- `POST /api/auth/login` — 用户登录,返回 JWT Token
- `POST /api/auth/refresh` — 刷新 Token
- `POST /api/auth/logout` — 登出
#### 对话接口
- `POST /api/chat` — 发送消息,获取 AI 回复
- `GET /api/conversations?page=&page_size=` — 获取对话历史列表
- `GET /api/conversations/{id}/messages?page=&page_size=` — 获取对话消息
#### 知识库接口
- `POST /api/documents/upload` — 上传文档(支持 multipart/form-data最大 50MB
- `GET /api/documents?page=&page_size=` — 获取文档列表
- `DELETE /api/documents/{id}` — 删除文档
- `POST /api/documents/{id}/reindex` — 重建索引(幂等操作)
- `POST /api/search` — 语义搜索
- 请求体:`{ "query": "搜索内容", "top_k": 5, "filters": {} }`
#### 知识图谱接口
- `GET /api/knowledge-graph` — 获取图谱数据
- `POST /api/knowledge-graph/rebuild` — 触发图谱重建(幂等,带锁防止并发)
- `GET /api/knowledge-graph/search?q=` — 搜索实体
#### 看板接口
- `GET /api/tasks?page=&page_size=&status=` — 获取任务列表
- `POST /api/tasks` — 创建任务
- `PUT /api/tasks/{id}` — 更新任务
- `DELETE /api/tasks/{id}` — 删除任务
#### 论坛接口
- `GET /api/forum/posts?page=&page_size=` — 获取帖子列表
- `POST /api/forum/posts` — 发布帖子
- `GET /api/forum/posts/{id}` — 获取帖子详情(含回复树)
- `POST /api/forum/posts/{id}/reply` — 回复帖子
#### Markdown 编辑器接口
- `GET /api/notes?page=&page_size=` — 获取笔记列表
- `POST /api/notes` — 创建笔记
- `PUT /api/notes/{id}` — 更新笔记
- `DELETE /api/notes/{id}` — 删除笔记
- `POST /api/notes/{id}/ai-assist` — AI 辅助操作
- 请求体:`{ "action": "continue" | "polish" | "summarize" }`
### 6.2 WebSocket 实时通信
消息格式统一为 JSON
```json
// 通用消息结构
{
"type": "chat_message" | "graph_update" | "task_update",
"payload": { ... },
"timestamp": "ISO8601"
}
```
- `/ws/chat` — 实时对话(流式输出)
- `/ws/knowledge-graph` — 图谱更新实时推送
- `/ws/tasks` — 任务状态变化实时推送
---
## 7. 前端设计
### 7.1 Web 端页面结构
```
┌─────────────────────────────────────────┐
│ 顶部导航栏 │
│ [对话] [知识库] [图谱] [看板] [论坛] [笔记] │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ │
│ 主内容区域 │
│ │
└─────────────────────────────────────────┘
```
### 7.2 核心页面
| 页面 | 功能 |
|------|------|
| **对话页** | 主对话界面Jarvis 头像,消息列表,输入框 |
| **知识库页** | 文档列表,上传入口,搜索框 |
| **图谱页** | 交互式知识图谱,节点详情侧边栏 |
| **看板页** | 任务看板Kanban 布局AI 规划建议 |
| **论坛页** | 帖子列表发帖入口AI 回复展示 |
| **笔记页** | Markdown 编辑器,笔记列表 |
### 7.3 Android 端
- 独立对话窗口,直接与 Jarvis 对话
- 任务查看和简单编辑
- 推送通知(每日规划提醒、任务到期提醒)
- 核心是**对话遥控**,重度操作建议用 Web 端
---
## 8. 部署架构
### 8.1 NAS 部署方案
```
┌──────────────────────────────────────────────────┐
│ NAS │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ Docker Compose │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────────┐ │ │
│ │ │ Jarvis API │ │ ChromaDB │ │ │
│ │ │ (FastAPI) │ │ (向量数据) │ │ │
│ │ └──────────────┘ └──────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────────┐ │ │
│ │ │ SQLite │ │ 文件存储 │ │ │
│ │ │ (关系数据) │ │ /data/files │ │ │
│ │ └──────────────┘ └──────────────────┘ │ │
│ └────────────────────────────────────────────┘ │
│ │
│ NAS 共享目录 /data 挂载到容器 │
└──────────────────────────────────────────────────┘
```
### 8.2 环境变量配置
```env
# LLM 配置
LLM_PROVIDER=claude # claude / deepseek / ollama
CLAUDE_API_KEY=xxx
DEEPSEEK_API_KEY=xxx
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama3
# 数据库配置
DATABASE_URL=sqlite+aiosqlite:///data/jarvis.db
CHROMA_PERSIST_DIR=/data/chroma
# 文件存储
FILE_STORAGE_DIR=/data/files
# 定时任务配置
FORUM_SCAN_INTERVAL=3600 # 秒
DAILY_PLAN_TIME=08:00
GRAPH_SYNC_TIME=02:00
# JWT 认证
JWT_SECRET=xxx
JWT_ALGORITHM=HS256
```
---
## 9. 安全设计
- **JWT 认证** — 所有 API 需要 Token 验证
- **数据加密** — SQLite 数据库可配置加密
- **文件隔离** — 用户上传文件存储在独立目录
- **API 限流** — 防止 API 滥用
- **敏感信息** — API Key 等存储在环境变量,不进入代码库
---
## 10. 未来扩展方向
- **多模态支持** — 图片、音频、视频解析
- **更多 Agent** — 按领域细分的专业助手
- **插件系统** — 第三方工具集成
- **团队协作** — 多用户知识共享
- **云端同步** — 异地数据备份
---
## 11. 开发阶段建议
> **注意**Phase 3 的知识图谱依赖 Phase 1 的知识库基础设施。Phase 1-3 为核心 MVP需按顺序开发。
| 阶段 | 内容 | 优先级 |
|------|------|--------|
| **Phase 1** | 基础框架搭建、对话系统、知识库上传检索 | P0 |
| **Phase 2** | 看板系统、论坛系统、Markdown 编辑器 | P0 |
| **Phase 3** | 知识图谱构建与可视化、多 Agent 协同 | P0 |
| **Phase 4** | 定时任务引擎、AI 每日规划功能 | P1 |
| **Phase 5** | Android App 开发 | P1 |
| **Phase 6** | 优化与扩展 | P2 |
---
*本文档为 Jarvis 个人 AI 助理系统的初始设计规格,将根据开发进展持续更新。*

View File

@@ -0,0 +1,141 @@
# LangSmith 集成设计文档
**日期**: 2026-03-20
**状态**: 设计中
**范围**: 后端 LangGraph Agent 追踪
---
## 1. 背景与目标
Jarvis 后端基于 LangGraph 构建了多智能体系统Master/Planner/Executor/Librarian/Analyst目前没有可观测性能力。
本次集成目标:
1. **调用追踪** — 在 LangSmith Dashboard 查看完整的 Agent 执行轨迹
2. **对话历史管理** — 按 run_id 聚合对话,自动存储到 LangSmith
3. **评估支持** — 积累的对话数据可用于 LangSmith Evaluation
---
## 2. 集成方案(方案 A最小集成
### 2.1 核心思路
LangGraph 内置对 LangSmith 的支持,只需三步即可完成集成:
1.`.env` 中配置 LangSmith 环境变量
2.`pyproject.toml` 中添加 `langsmith` 为直接依赖
3.`llm_service.py` 中为 LLM 调用注入 LangSmith Callback
LangGraph 的 `compile()` 会自动将 Callback 传递到所有节点,无需修改 `graph.py`
### 2.2 环境变量
`backend/.env.example` 中新增:
```env
# LangSmith Tracing
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=your-langsmith-api-key
LANGSMITH_PROJECT=jarvis-agent
```
### 2.3 依赖
`backend/pyproject.toml``dependencies` 中添加:
```toml
"langsmith>=0.1.0",
```
### 2.4 配置类变更
`backend/app/config.py` 中新增配置字段:
```python
# LangSmith
LANGSMITH_TRACING: bool = False
LANGSMITH_API_KEY: str = ""
LANGSMITH_PROJECT: str = "jarvis-agent"
```
### 2.5 实现变更
#### 2.5.1 Config 层
`backend/app/config.py` 中新增配置字段:
```python
LANGSMITH_TRACING: bool = False
LANGSMITH_API_KEY: str = ""
LANGSMITH_PROJECT: str = "jarvis-agent"
```
创建 `backend/app/config_tracing.py` 作为独立的 callback 工厂模块:
```python
from langchain_core.callbacks import LangChainTracer
from app.config import settings
def get_langsmith_callbacks() -> list:
if not settings.LANGSMITH_TRACING or not settings.LANGSMITH_API_KEY:
return []
return [LangChainTracer(project_name=settings.LANGSMITH_PROJECT)]
```
#### 2.5.2 Graph 层
`backend/app/agents/graph.py` 中:
1. `create_agent_graph()` 新增 `callbacks` 参数,透传给 `graph.compile(callbacks=...)`
2. `get_agent_graph()` 内部调用 `get_langsmith_callbacks()` 并与传入参数合并后传给 `create_agent_graph()`
LangGraph 的 `compile(callbacks=...)` 会自动将 callbacks 传播到所有节点的 LLM 调用,覆盖 Master/Planner/Executor/Librarian/Analyst 全部 5 个节点。
### 2.6 Streaming 兼容性
当前 streaming 通过 `graph.astream_events()` 实现。LangSmith Callback 会异步记录追踪数据,不影响流式输出的实时性。
如果需要在 streaming 过程中实时展示 trace URL可以在 `on_chat_model_end` 事件中从 `run.id` 生成链接:
```python
async for event in graph.astream_events(...):
if event["event"] == "on_chat_model_end":
run_id = event["data"]["output"].id # 从 response 中获取 run_id
trace_url = f"https://smith.langchain.com/runs/{run_id}"
```
---
## 3. 文件变更清单
| 文件 | 变更类型 |
|---|---|
| `backend/.env.example` | 新增 3 行环境变量 |
| `backend/pyproject.toml` | 新增 langsmith 依赖 |
| `backend/app/config.py` | 新增 3 个配置字段 |
| `backend/app/config_tracing.py` | 新建callback 工厂函数 |
| `backend/app/agents/graph.py` | `create_agent_graph`/`get_agent_graph` 支持 callbacks |
| `backend/app/services/agent_service.py` | `get_agent_graph()` 调用签名对齐 |
---
## 4. 风险与限制
- LangSmith 免费版有追踪数量限制(详见 LangSmith 定价)
- Streaming 模式下 trace 数据在调用结束后才完整展示
- 需要用户自行在 [langchain.com](https://smith.langchain.com) 注册并获取 API Key
---
## 5. 测试验证
集成完成后通过以下方式验证:
1. 设置 `LANGSMITH_TRACING=true` 并配置 API Key
2. 发起一次 Agent 对话
3. 在 LangSmith Dashboard 中查看对应的 trace确认包含
- 5 个节点的执行记录
- 每个节点的 LLM 输入/输出
- 工具调用记录
- Token 消耗统计

View File

@@ -0,0 +1,249 @@
# 注册界面 + 设置界面 功能设计
## 概述
为 Jarvis 系统添加用户注册功能和完整的设置界面。用户可以:
- 在前端注册账号
- 在设置界面管理个人信息和 LLM 配置
- 配置定时任务等系统参数
**核心价值:** 支持多用户、每个用户独立配置自己的 LLM 提供商和参数。
## 现状分析
### 已有的功能
- 后端已有 `/api/auth/register` API
- 后端使用 `pydantic-settings``.env` 读取配置
- 前端只有登录页面,无注册入口
### 需要改动的地方
- 前端 LoginView 添加注册表单
- User 模型增加 `llm_config``scheduler_config` JSON 字段
- 新建 Settings 路由和服务
- 新建 SettingsView 页面
## 数据模型
### User 表扩展
```sql
ALTER TABLE users ADD COLUMN llm_config TEXT;
ALTER TABLE users ADD COLUMN scheduler_config TEXT;
```
### 字段结构
**llm_config (JSON):**
```json
{
"chat": {
"provider": "openai|claude|ollama|deepseek|custom",
"model": "gpt-4o",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-..."
},
"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": "..."
}
}
```
**scheduler_config (JSON):**
```json
{
"daily_plan_time": "08:00",
"forum_scan_interval_minutes": 30,
"todo_ai_generate_time": "08:00",
"enabled": true
}
```
## API 设计
### 1. 注册 API (已有)
```
POST /api/auth/register
Body: { email, password, full_name }
Response: UserOut
```
### 2. 获取用户设置
```
GET /api/settings
Response: {
profile: { id, email, full_name, created_at },
llm_config: { ... },
scheduler_config: { ... }
}
```
### 3. 更新用户资料
```
PUT /api/settings/profile
Body: { full_name?, password? }
Response: UserOut
```
### 4. 更新 LLM 配置
```
PUT /api/settings/llm
Body: { chat?: {...}, vlm?: {...}, embedding?: {...}, rerank?: {...} }
Response: { llm_config: { ... } } // 返回更新后的完整配置
```
### 5. 测试 LLM 连接
```
POST /api/settings/llm/test
Body: { type: "chat"|"vlm"|"embedding"|"rerank", provider, model, base_url, api_key }
Response: { success: true, message: "连接成功" } 或 { success: false, error: "错误信息" }
```
### 6. 更新定时任务配置
```
PUT /api/settings/scheduler
Body: { daily_plan_time?, forum_scan_interval_minutes?, todo_ai_generate_time?, enabled? }
Response: { scheduler_config: { ... } } // 返回更新后的完整配置
```
## 前端页面
### LoginView.vue 改动
- 添加"注册"和"登录"切换 Tab
- 注册表单:邮箱、密码、确认密码、用户名
- 复用现有 sci-fi 登录风格
### SettingsView.vue (新建)
#### 页面布局
```
┌─────────────────────────────────────────────────┐
│ [⚙] SETTINGS │
├─────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────┐ │
│ │ PROFILE │ │
│ │ Email: operator@jarvis.ai │ │
│ │ Name: [___________] │ │
│ │ Password: [********] [Change] │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ LLM CONFIGURATION │ │
│ │ ┌─ Chat ────────────────────────────────┐ │ │
│ │ │ Provider: [OpenAI ▼] │ │ │
│ │ │ Model: [gpt-4o ____________] │ │ │
│ │ │ Base URL:[https://...] ] │ │ │
│ │ │ API Key: [•••••••••••••••••] │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ ┌─ VLM ─────────────────────────────────┐ │ │
│ │ │ ... (同上结构) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ ┌─ Embedding ───────────────────────────┐ │ │
│ │ │ ... (同上结构) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ ┌─ Rerank ──────────────────────────────┐ │ │
│ │ │ ... (同上结构) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ SCHEDULER │ │
│ │ Daily Plan Time: [08:00] │ │
│ │ Forum Scan Interval: [30] 分钟 │ │
│ │ Todo AI Generate: [08:00] │ │
│ │ Scheduler Enabled: [ON] │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ [SAVE ALL SETTINGS] │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
#### 交互行为
- 修改后点击"保存"按钮,按钮显示 loading 状态
- 保存成功显示 toast 提示"保存成功"
- 保存失败显示 toast 提示错误信息
- 密码修改需二次确认弹窗
- API Key 字段支持显示/隐藏切换
- 每个模型配置卡片有独立的"测试"按钮
- Provider 切换时自动填充默认值(如 Ollama 切换到 localhost:11434
- Scheduler enabled 关闭时,时间输入框显示禁用状态
- 空配置时显示"点击配置"占位提示
#### 注册表单
- 邮箱:必填,格式校验
- 用户名必填2-20 字符
- 密码:必填,最少 8 字符
- 确认密码:必填,需与密码一致
- 密码强度指示器(弱/中/强)
## 路由和侧边栏
### router/index.ts
```typescript
{
path: 'settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
}
```
### SidebarNav.vue
```typescript
{ name: '设置', path: '/settings', icon: Settings }
```
## 技术实现
### 后端文件
```
backend/app/
models/
user.py # 修改:添加 llm_config, scheduler_config 字段
schemas/
auth.py # 修改UserCreate 支持 full_name
settings.py # 新建SettingsOut, LLMConfigIn, SchedulerConfigIn
routers/
settings.py # 新建settings 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 # 修改:添加设置菜单
```
## 验证清单
1. 注册功能正常 - 可以通过注册页面创建新账号
2. 登录功能正常 - 新老用户都可以登录
3. 设置页面可访问 - 登录后可进入设置页面
4. 个人信息修改正常 - 用户名、密码可修改
5. LLM 配置保存正常 - 四种模型配置可保存
6. LLM 测试连接正常 - 可以验证配置是否正确
7. 定时任务配置正常 - 时间间隔可修改
8. 配置持久化正常 - 重新登录后配置保留
9. UI 风格一致 - 设置页面与其他页面风格统一
10. 注册表单验证正常 - 密码强度、格式校验有效

View File

@@ -0,0 +1,267 @@
# 数据统计页面重新设计
## 1. 概述与目标
重新设计数据统计页面,使其与项目现有的深色赛博朋克/终端风格保持一致。采用单页垂直滚动布局,集成迷你图表,提供清晰的数据可视化。
## 2. 设计语言
### 视觉风格
- **主题**:深色赛博朋克 + 终端美学
- **背景**`var(--bg-void)` 深空黑
- **强调色**:青色 `#00f5d4` (现有变量 `var(--accent-cyan)`)
- **辅助色**:紫色 `#a855f7` (用于知识库等模块)
- **卡片背景**`rgba(13,21,37,0.8)` 半透明深蓝
- **边框**`1px solid var(--border-dim)`hover时发光
### 字体
- **数字**:等宽字体 `var(--font-mono)`,大号加粗,带发光效果
- **标签**`var(--font-display)`9-10px字母间距 0.15em
- **正文**`var(--font-mono)`12-13px
### 动效
- 卡片 hover边框发光 + 微弱上浮
- 数字:首次加载时淡入
- 图表:绘制动画 300ms
## 3. 页面结构
### 单页垂直滚动布局(无 Tabs
```
┌─────────────────────────────────────────────────────┐
│ // DATA METRICS [页面标题] │
├─────────────────────────────────────────────────────┤
│ [SYSTEM HEALTH] 系统健康模块 │
│ [CONVERSATIONS] 对话统计模块 │
│ [KNOWLEDGE] 知识库模块 │
│ [KANBAN] 看板模块 │
│ [COMMUNITY] 社区模块 │
│ [INSIGHTS] 个人洞察模块 │
└─────────────────────────────────────────────────────┘
```
## 4. 模块详细设计
### 4.1 系统健康 (SYSTEM HEALTH)
**位置**:页面最顶部,无需认证即可访问
**卡片布局**4列网格
**指标卡片**
| 指标 | 图标 | 格式 |
|------|------|------|
| CPU 使用率 | Cpu | 45% + 7天迷你柱状图 |
| 内存占用 | MemoryStick | 62% + 7天迷你柱状图 |
| 磁盘使用 | HardDrive | 38% + 7天迷你柱状图 |
| 运行时间 | Clock | 7d 3h 20m |
**卡片样式**
- 尺寸:自适应,最小 160px
- 数字大小24px等宽加粗
- 趋势图:高度 24px7个数据点
- 标签9pxletter-spacing 0.15em
### 4.2 对话统计 (CONVERSATIONS)
**需要认证**
**顶部汇总**横排4个数字卡片
| 指标 | 值 |
|------|-----|
| 总对话数 | 1,234 |
| 总消息数 | 5,678 |
| Input Tokens | 12.5M |
| Output Tokens | 45.2M |
**图表**30天趋势迷你折线图
- 4条线对话数、消息数、Input Token、Output Token
- 图例在图表下方
- 图表高度120px
- 颜色使用主题色
### 4.3 知识库 (KNOWLEDGE)
**需要认证**
**顶部汇总**
| 指标 | 值 |
|------|-----|
| 新建标签 | 156 |
| 文档数 | 89 |
| 标签关系 | 423 |
**图表**30天趋势迷你折线图
- 3条线新建标签、文档、标签关系
- 使用紫色系 `var(--accent-purple)`
### 4.4 看板 (KANBAN)
**需要认证**
**顶部汇总**
| 指标 | 值 |
|------|-----|
| 待办任务 | 12 |
| 新建任务 | 45 (30天) |
| 已完成任务 | 38 (30天) |
**图表**30天对比柱状图
- 两组柱:新建任务 vs 已完成任务
- 使用青色和绿色对比
### 4.5 社区 (COMMUNITY)
**需要认证**
**顶部汇总**
| 指标 | 值 |
|------|-----|
| 发帖数 | 23 |
| 回复数 | 156 |
| AI 执行 | 12 |
**图表**30天趋势迷你折线图
- 3条线发帖、回复、AI执行
### 4.6 个人洞察 (INSIGHTS)
**需要认证**
**布局**2列
**左侧 - 活跃时段**
- 24小时柱状图
- 显示高峰时段标记
**右侧 - Top 标签**
- 列表形式显示前5个常用标签
- 显示使用次数
**Token趋势**
- 本月 vs 上月对比
- 百分比变化(带颜色指示上升/下降)
## 5. 组件清单
### MetricCard 指标卡片
```
Props:
- icon: Component (lucide图标)
- label: string
- value: string | number
- trend?: number[] (可选,迷你图数据)
- accentColor?: string (默认cyan)
States:
- default: 静态显示
- hover: 边框发光,轻微上浮
- loading: 骨架屏
- error: 显示 "--" 和错误图标
```
### MiniLineChart 迷你折线图
```
Props:
- data: { date: string, value: number }[]
- color?: string
- height?: number (默认40px)
Features:
- 纯CSS实现或tiny echarts
- 无坐标轴,仅保留趋势
- 数据点过多时自动采样
```
### MiniBarChart 迷你柱状图
```
Props:
- data: number[]
- color?: string
- height?: number (默认24px)
- maxBars?: number (默认7)
```
### SectionHeader 区块标题
```
Props:
- title: string
- tag?: 'cyan' | 'purple' | 'amber' (标签颜色)
Style:
- 格式:// SECTION_NAME
- 左侧竖线装饰
- 标签 Chip 在右侧
```
### SummaryRow 汇总行
```
Props:
- items: { label: string, value: string | number }[]
- columns?: number (默认4)
```
## 6. 技术实现
### 前端
- **框架**Vue 3 + TypeScript (已有)
- **图表库**:使用 CSS 实现迷你图,或 echarts (已有)
- **图标**lucide-vue-next (已有)
- **状态管理**Pinia (已有)
- **API**StatsView 中已有 stats API 调用
### 后端
- 复用现有 `app/routers/stats.py``app/services/stats_service.py`
- 确保所有接口正确返回数据
### 样式
- 复用 `ChatView.vue` 中的设计变量和样式模式
- 使用 CSS Grid 实现响应式布局
- 变量:`--bg-panel`, `--accent-cyan`, `--border-dim`, `--font-mono`
## 7. 响应式断点
| 设备 | 列数 |
|------|------|
| >= 1200px | 4列 |
| 768px - 1199px | 2列 |
| < 768px | 1列 |
## 8. 错误与空状态
### Error State
- 显示错误图标和文字
- 提供刷新按钮
- 保持页面结构完整
### Empty State
- 各模块独立空状态
- 不阻塞其他模块显示
- 友好提示文案
### Loading State
- 骨架屏动画
- 与卡片结构一致
## 9. 访问控制
| 模块 | 认证要求 | 说明 |
|------|----------|------|
| 系统健康 | 否 | 所有人可看 |
| 对话统计 | 是 | 需登录 |
| 知识库 | 是 | 需登录 |
| 看板 | 是 | 需登录 |
| 社区 | 是 | 需登录 |
| 个人洞察 | 是 | 需登录 |
未登录用户访问需认证模块时:
- 显示占位卡片结构
- 提示"请先登录"
- 不发送无效请求
## 10. 数据刷新
- 页面进入时加载所有数据
- 支持手动刷新按钮(每个模块独立刷新)
- 数字变化时无动画(避免干扰)

View File

@@ -0,0 +1,473 @@
# 交互广场重新设计
## 1. 概述与目标
将现有的论坛(交互广场)从传统的帖子/回复模式重构为三个AI驱动的智能板块
1. **AI学习板块** - 模型分析用户活动,学习客观知识并加入知识图谱,向用户汇报学习成果
2. **AI建议板块** - 基于用户习惯和数据,提供个性化建议
3. **AI交互板块** - 用户发起学习主题或AI主动探索补充知识
## 2. 设计风格
沿用项目现有的深色赛博朋克/终端风格:
- 背景:`var(--bg-void)` 深空黑
- 强调色:紫色 `#a855f7` (用于交互广场专属色调)
- 卡片背景:`var(--bg-card)`
- 边框:`1px solid var(--border-dim)`hover时发光
- 字体:等宽字体 `var(--font-mono)`,标题用 `var(--font-display)`
## 3. 页面结构
```
┌─────────────────────────────────────────────────────────────┐
│ // INTERACTIVE PLAZA [页面标题] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [MODEL LEARNING] AI学习板块 │ │
│ │ AI分析你的活动学习知识并汇报 │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ • 今日学习摘要 │ │
│ │ • 学习历史时间线 │ │
│ │ • 知识图谱更新统计 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [SUGGESTIONS] AI建议板块 │ │
│ │ 基于你的习惯提供个性化建议 │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ • 知识补充建议 │ │
│ │ • 效率优化建议 │ │
│ │ • 技能深耕建议 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [INTERACTIVE] AI交互学习板块 │ │
│ │ 用户发起学习主题AI主动探索 │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ • 用户发起的学习主题 │ │
│ │ • AI主动学习的内容 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
## 4. 功能详情
### 4.1 AI学习板块 (MODEL LEARNING)
**数据来源:**
- 对话记录(`messages`Message模型- 分析对话内容提取概念
- 看板任务(`tasks`Task模型- 识别技术栈和工作流程
- 知识库(`documents`, `kg_nodes` 表)- 补充知识缺口
**学习流程:**
```
定时任务触发 → 分析近期活动 → 提取概念/术语/事实
→ 存入知识图谱(KGNode) → 生成学习报告 → 存入learning_records表
```
**数据库扩展:**
```python
# 新增 learning_records 表
# 继承 app.models.base.BaseModel自动获得 id, created_at, updated_at
from app.models.base import BaseModel
class LearningRecord(BaseModel):
__tablename__ = "learning_records"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
learning_type = Column(String(50), nullable=False) # concept, technology, workflow
topic = Column(String(500), nullable=False) # 学习主题
summary = Column(Text, nullable=False) # AI生成的学习摘要
source = Column(String(50), nullable=False) # conversation, kanban, knowledge
source_ids = Column(JSON, nullable=True) # 来源ID列表如 {conversation_ids: [...], task_ids: [...]}
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
```
**KGNode实体类型扩展**
- `learned_concept` - 从对话中学到的概念
- `technology` - 识别出的技术栈
- `workflow` - 从看板任务中提取的工作流程
**前端展示:**
1. **今日学习摘要卡片**
- AI生成的自然语言总结
- 示例:"今日学习了依赖注入和异步编程两个概念它们都来自你关于FastAPI的讨论"
- 显示来源标签:对话/看板/知识库
2. **学习历史时间线**
- 垂直时间线布局
- 每条记录显示:时间、主题、摘要
- 点击展开查看详情
3. **知识图谱更新统计**
- 今日新增节点数
- 今日新建关系数
- 迷你柱状图显示各类别占比(可复用 MiniBarChart
### 4.2 AI建议板块 (SUGGESTIONS)
**建议类型:**
1. **知识补充建议 (knowledge)**
- 检测知识图谱薄弱领域
- 基于用户提问推断知识缺口
- 示例:"你的知识图谱在'微服务架构'领域较为薄弱,建议深入学习"
2. **效率优化建议 (efficiency)**
- 分析用户使用模式
- 推荐最佳实践
- 示例:"你通常在下午工作效率最高,建议将复杂任务安排在这个时段"
3. **技能深耕建议 (skill)**
- 基于高频话题
- 推荐深入学习方向
- 示例:"你最近频繁讨论API设计建议学习REST最佳实践和GraphQL"
**数据库扩展:**
```python
# 新增 suggestions 表
from app.models.base import BaseModel
class Suggestion(BaseModel):
__tablename__ = "suggestions"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
suggestion_type = Column(String(50), nullable=False) # knowledge, efficiency, skill
title = Column(String(500), nullable=False) # 建议标题
content = Column(Text, nullable=False) # 建议内容
source_data = Column(JSON, nullable=True) # 分析依据,如 {knowledge_gaps: [...], usage_patterns: {...}}
is_read = Column(Boolean, default=False) # 是否已读
is_dismissed = Column(Boolean, default=False) # 是否忽略
```
**前端展示:**
- 卡片列表布局
- 每个建议显示:图标、类型标签、标题、内容
- 右侧显示建议来源分析
- 提供"查看详情"和"忽略"按钮
### 4.3 AI交互板块 (INTERACTIVE)
**用户发起学习:**
1. 用户输入想学习的主题
2. AI分析主题搜索知识库
3. 如有需要AI主动抓取外部资源
4. 生成学习报告
5. 自动存入知识图谱
6. 在交互板块展示
**数据库扩展:**
```python
# 新增 interactive_topics 表
from app.models.base import BaseModel
class InteractiveTopic(BaseModel):
__tablename__ = "interactive_topics"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
topic = Column(String(500), nullable=False) # 学习主题
status = Column(String(50), nullable=False) # pending, learning, completed, failed
result = Column(Text, nullable=True) # 学习结果/报告
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
source = Column(String(50), nullable=False) # user_initiated, ai_proactive
completed_at = Column(DateTime, nullable=True)
```
**AI主动学习**
1. AI分析用户历史提问
2. 发现知识缺口或关联主题
3. 主动学习并生成报告
4. 在交互板块标记为"AI主动"
**前端展示:**
- 两个子区块:用户发起 / AI主动
- 输入框:"让AI学习 [主题]"
- 正在进行的学习任务显示进度
- 已完成的学习显示结果摘要
## 5. API 设计
### 5.1 后端接口
```
GET /api/forum/learning/summary
- 获取今日学习摘要
- 返回: { summary, records[], stats{ nodes_created, edges_created } }
GET /api/forum/learning/history?page=1&limit=20
- 获取学习历史
- 返回: { records[], total }
GET /api/forum/suggestions
- 获取所有建议
- 返回: { suggestions[] }
GET /api/forum/suggestions/{id}
- 获取单个建议详情
- 返回: Suggestion
PATCH /api/forum/suggestions/{id}/read
- 标记建议为已读
DELETE /api/forum/suggestions/{id}/dismiss
- 忽略/删除建议
GET /api/forum/interactive/topics
- 获取交互主题列表
- 返回: { user_initiated[], ai_proactive[] }
POST /api/forum/interactive/learn
- 用户发起学习
- Body: { topic: string }
- 返回: { topic_id, status }
GET /api/forum/interactive/topics/{id}
- 获取学习主题详情/结果
```
### 5.2 前端API
```typescript
// TypeScript 类型定义
interface LearningSummary {
summary: string
records: LearningRecord[]
stats: {
nodes_created: number
edges_created: number
}
}
interface LearningRecord {
id: string
learning_type: 'concept' | 'technology' | 'workflow'
topic: string
summary: string
source: string
source_ids?: { conversation_ids?: string[]; task_ids?: string[] }
kg_nodes_created?: string[]
created_at: string
}
interface Suggestion {
id: string
suggestion_type: 'knowledge' | 'efficiency' | 'skill'
title: string
content: string
source_data?: Record<string, any>
is_read: boolean
is_dismissed: boolean
created_at: string
}
interface InteractiveTopic {
id: string
topic: string
status: 'pending' | 'learning' | 'completed' | 'failed'
result?: string
kg_nodes_created?: string[]
source: 'user_initiated' | 'ai_proactive'
created_at: string
completed_at?: string
}
// API 方法
const forumApi = {
// learning
fetchLearningSummary(): Promise<LearningSummary>,
fetchLearningHistory(params: { page: number, limit: number }): Promise<{ records: LearningRecord[], total: number }>,
// suggestions
fetchSuggestions(): Promise<Suggestion[]>,
getSuggestion(id: string): Promise<Suggestion>,
markSuggestionRead(id: string): Promise<void>,
dismissSuggestion(id: string): Promise<void>,
// interactive
fetchInteractiveTopics(): Promise<{ user_initiated: InteractiveTopic[], ai_proactive: InteractiveTopic[] }>,
initiateLearning(topic: string): Promise<InteractiveTopic>,
getTopicDetail(id: string): Promise<InteractiveTopic>,
}
```
## 6. 组件结构
```
frontend/src/views/ForumView.vue # 主页面,三板块布局
frontend/src/components/forum/
├── LearningSection.vue # AI学习板块
│ ├── LearningSummaryCard.vue # 今日摘要卡片
│ ├── LearningTimeline.vue # 学习历史时间线
│ └── LearningStats.vue # 图谱更新统计复用MiniBarChart
├── SuggestionSection.vue # AI建议板块
│ ├── SuggestionCard.vue # 建议卡片
│ └── SuggestionList.vue # 建议列表
└── InteractiveSection.vue # AI交互板块
├── LearningInput.vue # 学习主题输入框
├── UserInitiatedList.vue # 用户发起列表
└── AIProactiveList.vue # AI主动列表
# 新增通用组件
frontend/src/components/forum/MiniDonutChart.vue # 环形图(用于知识类别占比)
```
## 7. 服务层
### 7.1 LearningService
```python
from app.core.llm import get_llm_client
class LearningService:
def __init__(self, db: AsyncSession):
self.llm = get_llm_client()
async def generate_daily_summary(user_id: str) -> str:
"""分析用户今日活动,生成学习摘要"""
# 使用 LLM 分析提取的概念,生成自然语言摘要
concepts = await self.extract_concepts(...)
prompt = f"根据以下学习内容生成简短摘要:{concepts}"
return await self.llm.chat(prompt)
async def extract_concepts_from_conversations(user_id: str, since: datetime) -> list[dict]:
"""从对话中提取概念"""
async def identify_technologies_from_kanban(user_id: str) -> list[dict]:
"""从看板任务中识别技术栈"""
async def create_kg_nodes(user_id: str, learnings: list[dict]) -> list[str]:
"""创建知识图谱节点"""
async def record_learning(...) -> LearningRecord:
"""记录学习成果"""
```
### 7.2 SuggestionService
```python
class SuggestionService:
def __init__(self, db: AsyncSession):
self.llm = get_llm_client()
async def generate_suggestions(user_id: str) -> list[Suggestion]:
"""生成个性化建议"""
# 分析知识缺口、使用模式、技能机会
gaps = await self.analyze_knowledge_gaps(user_id)
patterns = await self.analyze_usage_patterns(user_id)
skills = await self.analyze_skill_opportunities(user_id)
# 使用 LLM 生成建议
prompt = f"基于以下分析生成建议:知识缺口{gaps},使用模式{patterns},技能机会{skills}"
return await self.llm.chat(prompt)
async def analyze_knowledge_gaps(user_id: str) -> list[dict]:
"""分析知识图谱缺口"""
async def analyze_usage_patterns(user_id: str) -> dict:
"""分析使用模式"""
async def identify_skill_opportunities(user_id: str) -> list[dict]:
"""识别技能提升机会"""
```
### 7.3 InteractiveService
```python
class InteractiveService:
def __init__(self, db: AsyncSession):
self.llm = get_llm_client()
async def initiate_learning(user_id: str, topic: str) -> InteractiveTopic:
"""用户发起学习"""
async def execute_learning(topic_id: str) -> dict:
"""执行学习任务:
1. 搜索知识库相关节点
2. 使用 LLM 深入学习主题
3. 生成学习报告
4. 创建 KGNode
5. 更新 topic 状态
"""
topic = await self.get_topic(topic_id)
content = await self.research_topic(topic.topic)
report = await self.generate_learning_report(topic, content)
await self.create_kg_nodes_from_report(report)
await self.update_topic_status(topic_id, 'completed', report)
async def generate_learning_report(self, topic: InteractiveTopic, content: str) -> str:
"""使用 LLM 生成结构化学习报告"""
```
## 8. 定时任务
每日凌晨生成学习报告:
- 分析昨日用户活动
- 提取新概念和技术栈
- 更新知识图谱
- 生成学习摘要存入数据库
**集成方式:** 使用项目现有的 `scheduler_service.py`
```python
# 在 scheduler_service.py 的 start_scheduler() 中添加
from app.services.learning_service import LearningService
async def daily_learning_job():
"""每日凌晨0:30生成学习报告"""
from app.database import get_db_session
async for db in get_db_session():
service = LearningService(db)
users = await get_all_active_users(db)
for user in users:
await service.generate_and_record_daily_learning(user.id)
break
# 在 start_scheduler() 中注册
scheduler.add_job(daily_learning_job, "cron", hour=0, minute=30, id="daily_learning")
```
## 9. 错误处理
| 场景 | 处理 |
|------|------|
| 无活动数据 | 显示"今日暂无学习成果",不生成空记录 |
| 知识图谱更新失败 | 回滚学习记录,标记为失败状态 |
| AI生成失败 | 记录原始数据,标记需要重试 |
| 用户发起学习主题为空 | 前端验证拦截,不发送请求 |
## 10. 访问控制
所有板块需要用户登录后访问:
- 未登录用户显示"请先登录"提示
- 不发送无效API请求
- 保持页面结构完整
## 11. 技术实现
**前端:**
- Vue 3 + TypeScript
- 复用现有组件样式StatsView.vue模式
- CSS实现迷你图表
- lucide-vue-next图标
**后端:**
- FastAPI + SQLAlchemy
- 复用现有数据库连接
- 新增三个Service类
- 复用现有认证机制
**数据流:**
```
用户活动 → LearningService分析 → KGNode创建 → LearningRecord存储
AI生成摘要 → 前端展示
```

View File

@@ -0,0 +1,307 @@
# 知识库文件夹分层设计
> **Goal:** 为知识库添加文件夹分层组织功能支持多层嵌套、CRUD 操作,支持知识大脑汇聚各类内容。
## 1. 概念与愿景
知识库是用户的**资料中枢**,文件夹分层让知识更有序。用户可以按主题/项目/类型建立文件夹层级,如 `技术文档/Python/入门.pdf`
知识大脑会汇聚来自知识库、待办、看板、论坛、对话的内容,形成完整的用户知识画像。文件夹是知识的入口分类,而非知识图谱的一部分。
## 2. 数据模型
### 2.1 Folder 表(邻接表模式)
```python
class Folder(BaseModel):
__tablename__ = "folders"
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) # NULL=根目录
# 注意: id, created_at, updated_at 继承自 BaseModel
```
**特点:**
- 邻接表模式:通过 `parent_id` 指向父文件夹
- 根目录文件夹的 `parent_id = NULL`
- 查询完整树结构使用递归 CTE
- **唯一约束**`user_id + parent_id + name` 组合唯一,防止同级重名
**验证规则:**
- 文件夹名称不能为空,最大 255 字符
- 不允许包含字符:`/ \ * ? :`
- 最大嵌套深度10 层(防止 UI/性能问题)
### 2.2 Document 表变更
```python
class Document(BaseModel):
# ...现有字段...
folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # 新增
```
**约定:**
- `folder_id = NULL` 表示文档在根目录(未分类)
- 删除文件夹时,级联删除该文件夹及其所有子文件夹中的文档
### 2.3 ChromaDB Metadata
```python
{
"document_id": "xxx",
"document_title": "入门.pdf",
"folder_path": "/技术文档/Python", # 完整路径,用于检索过滤
"file_type": "pdf",
"chunk_index": 0,
}
```
## 3. API 接口
### 3.1 文件夹管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/folders` | 获取用户的完整文件夹树 |
| POST | `/api/folders` | 创建文件夹 `{ name, parent_id? }` |
| PUT | `/api/folders/{id}` | 重命名文件夹 `{ name }` |
| DELETE | `/api/folders/{id}` | 删除文件夹(级联删除文档) |
**GET /api/folders 响应:**
```json
{
"folders": [
{
"id": "xxx",
"name": "技术文档",
"parent_id": null,
"children": [
{
"id": "yyy",
"name": "Python",
"parent_id": "xxx",
"children": []
}
]
}
]
}
```
### 3.2 文档管理变更
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/documents?folder_id=` | 按文件夹查询文档 |
| POST | `/api/documents` | 上传文档时指定 `folder_id` |
| DELETE | `/api/documents/{id}` | 删除文档 |
**POST /api/documents 请求增加可选字段:**
```json
{
"file": "<binary>",
"folder_id": "yyy" // 可选,不传表示根目录
}
```
### 3.3 安全与权限
**所有权验证:**
- 所有文件夹操作必须验证 `folder.user_id == current_user.id`
- 文档操作时验证 `document.user_id == current_user.id`
- `folder_id` 参数需要验证归属,防止跨用户访问
**示例中间件:**
```python
async def verify_folder_access(folder_id: str, user_id: str, db: AsyncSession):
result = await db.execute(
select(Folder).where(Folder.id == folder_id, Folder.user_id == user_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="无权访问此文件夹")
```
### 3.4 向量检索变更
`KnowledgeService.retrieve()` 增加可选参数 `folder_id`
```python
async def retrieve(
self,
query: str,
user_id: str,
folder_id: str | None = None, # 新增
top_k: int = 5,
):
# 如果指定 folder_id构建 path 前缀过滤
folder_path = await self._get_folder_path(folder_id)
where = {"folder_path": {"$starts_with": folder_path}} if folder_path else None
```
### 3.5 ChromaDB 同步策略
**文件夹重命名/移动时的同步:**
由于 ChromaDB metadata 中存储了 `folder_path`,当文件夹路径变化时需要同步更新:
```python
async def update_folder_paths(folder_id: str, old_path: str, new_path: str):
"""更新所有子文件夹和文档的路径"""
# 1. 更新所有子文件夹的 path
children = await db.execute(
select(Folder).where(Folder.parent_id == folder_id)
)
for child in children.scalars():
child_new_path = new_path + "/" + child.name
await update_folder_paths(child.id, old_path + "/" + child.name, child_new_path)
# 2. 更新该文件夹下所有文档的 ChromaDB metadata
docs = await db.execute(
select(Document).where(Document.folder_id == folder_id)
)
for doc in docs.scalars():
collection.update(
where={"document_id": doc.id},
set={"folder_path": new_path}
)
```
**删除文件夹时的清理:**
```python
async def delete_folder_cascade(folder_id: str):
"""级联删除:先删子文件夹,再删文档,最后删自己"""
# 1. 递归删除所有子文件夹
children = await db.execute(
select(Folder).where(Folder.parent_id == folder_id)
)
for child in children.scalars():
await delete_folder_cascade(child.id)
# 2. 删除该文件夹下所有文档(从 ChromaDB 和数据库)
docs = await db.execute(
select(Document).where(Document.folder_id == folder_id)
)
for doc in docs.scalars():
await knowledge_service.delete_from_vectorstore(user_id, doc.id)
await db.delete(doc)
# 3. 删除文件夹本身
folder = await db.get(Folder, folder_id)
await db.delete(folder)
```
## 4. 前端设计
### 4.1 布局结构
```
┌─────────────────────────────────────────────────────────┐
│ KNOWLEDGE BASE [+新建文件夹] [+上传] │
├──────────────┬──────────────────────────────────────────┤
│ │ │
│ 📁 技术文档 │ 搜索栏 [🔍 搜索...] [混合▼] │
│ 📁 Python │ │
│ 📄 入门 │ ┌─────────────────────────────────┐ │
│ 📄 进阶 │ │ 文档标题 │ │
│ 📁 Vue │ │ 类型 · 大小 · 状态 │ │
│ 📁 产品 │ └─────────────────────────────────┘ │
│ │ │
│ 📁 临时文件 │ ┌─────────────────────────────────┐ │
│ │ │ ... │ │
│ │ └─────────────────────────────────┘ │
└──────────────┴──────────────────────────────────────────┘
```
### 4.2 组件结构
```
KnowledgeView
├── Header (标题 + 操作按钮)
├── MainLayout (flexbox: sidebar + content)
│ ├── FolderTree (左侧边栏)
│ │ ├── FolderItem (递归组件)
│ │ │ ├── folder icon + name
│ │ │ ├── children (递归)
│ │ │ └── context menu (右键: 重命名/删除)
│ │ └── AddFolderButton
│ │
│ └── ContentArea (右侧主区域)
│ ├── SearchBar
│ ├── UploadZone
│ ├── DocumentList
│ └── SearchResults
```
### 4.3 交互细节
| 操作 | 行为 |
|------|------|
| 点击文件夹 | 高亮选中,显示该文件夹下文档 |
| 右键文件夹 | 弹出菜单:重命名 / 删除 |
| 双击文件夹名 | 进入编辑状态 |
| 新建文件夹 | 弹出输入框,默认在当前选中位置创建 |
| 上传文档 | 需先选择目标文件夹,否则默认根目录 |
| 搜索 | 可选限定在当前文件夹内搜索 |
### 4.4 UI 风格
保持一致的 sci-fi holographic 风格:
- 主色调:青色 `#00f5d4` + 深色背景
- 文件夹图标:使用 Folder/FolderOpen 图标
- 悬停/选中状态:边框高亮 + 背景色变化
- 动画:展开/折叠动画 200ms ease
## 5. 实施步骤
### Phase 1: 数据层
1. 创建 `Folder` 模型和表
2. 修改 `Document` 模型,增加 `folder_id` 外键
3. 添加数据库迁移
### Phase 2: 后端 API
1. 实现文件夹 CRUD 接口
2. 修改文档上传接口,支持 `folder_id`
3. 修改文档列表接口,支持 `folder_id` 过滤
4. 修改向量检索,支持 `folder_id` 范围限定
5. 实现递归 CTE 查询文件夹树
**递归 CTE 示例(获取完整文件夹树):**
```sql
WITH RECURSIVE folder_tree AS (
-- 基础查询:根文件夹
SELECT id, name, parent_id, 0 as depth
FROM folders
WHERE user_id = :user_id AND parent_id IS NULL
UNION ALL
-- 递归查询:子文件夹
SELECT f.id, f.name, f.parent_id, ft.depth + 1
FROM folders f
INNER JOIN folder_tree ft ON ft.id = f.parent_id
WHERE f.user_id = :user_id
)
SELECT * FROM folder_tree ORDER BY depth, name;
```
### Phase 3: 前端
1. 创建 `FolderTree` 组件
2. 改造 `KnowledgeView` 布局
3. 实现文件夹右键菜单(重命名/删除)
4. 实现新建文件夹弹窗
5. 上传时强制选择文件夹
### Phase 4: 测试
1. 文件夹 CRUD 测试
2. 级联删除测试(删除文件夹 + 文档)
3. 向量检索按文件夹过滤测试
4. 前端交互测试
## 6. 技术约束
- SQLite 的递归 CTE 查询文件夹树
- 删除文件夹时先删除子文件夹(递归),再删除文档
- ChromaDB 的 `where` 过滤使用 `$starts_with` 做路径前缀匹配
- 前端递归组件注意防止无限循环