diff --git a/docs/superpowers/implementation/phase-1-plan.md b/docs/superpowers/implementation/phase-1-plan.md new file mode 100644 index 0000000..ba62f22 --- /dev/null +++ b/docs/superpowers/implementation/phase-1-plan.md @@ -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(可切换) | +| **知识库框架** | LlamaIndex(Node 关系索引、语义检索) | +| **向量数据库** | ChromaDB | +| **关系数据库** | SQLite + SQLAlchemy | +| **前端框架** | Vue 3 + TypeScript + Vite | +| **移动端** | Kotlin (Android) | +| **定时任务** | APScheduler | +| **部署** | Docker(NAS 本地运行) | + +--- + +## 目录结构 + +``` +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` + +是否现在开始? diff --git a/docs/superpowers/plans/2026-03-20-chat-enhancement-implementation.md b/docs/superpowers/plans/2026-03-20-chat-enhancement-implementation.md new file mode 100644 index 0000000..d132928 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-chat-enhancement-implementation.md @@ -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_ids,AgentService 读取文件内容作为上下文 + +**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 + + + + + +``` + +--- + +## Task 2: 创建 FileMessage 组件 + +**Files:** +- Create: `frontend/src/components/chat/FileMessage.vue` + +- [ ] **Step 1: 创建 FileMessage.vue** + +```vue + + + + + +``` + +--- + +## 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(`/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() +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 + + + + + + + +
+ + +
+``` + +- [ ] **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** - 在当前会话中按批次执行任务 + +选择哪种方式? diff --git a/docs/superpowers/plans/2026-03-20-daily-todo-migration.md b/docs/superpowers/plans/2026-03-20-daily-todo-migration.md new file mode 100644 index 0000000..3775fc3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-daily-todo-migration.md @@ -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 | 更新时间 | diff --git a/docs/superpowers/plans/2026-03-20-daily-todo-plan.md b/docs/superpowers/plans/2026-03-20-daily-todo-plan.md new file mode 100644 index 0000000..19cd524 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-daily-todo-plan.md @@ -0,0 +1,1184 @@ +# Daily Todo 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:** +- 后端:FastAPI Router + SQLAlchemy Model + LLM Service 分析对话 +- 前端:Vue 3 Composition API,TodoView 独立页面 +- 数据库:新表 `daily_todos`,按日期过滤查询 + +**Tech Stack:** FastAPI + SQLAlchemy + APScheduler + LLM Service / Vue 3 + Motion + axios + +--- + +## 文件总览 + +``` +backend/ + app/ + models/ + __init__.py # 修改:导出 DailyTodo + todo.py # 新建:DailyTodo 模型 + schemas/ + todo.py # 新建:Pydantic Schema + routers/ + todo.py # 新建:API Router + services/ + todo_service.py # 新建:AI 分析逻辑 + main.py # 修改:注册 todo router + +frontend/ + src/ + api/ + todo.ts # 新建:API 客户端 + views/ + TodoView.vue # 新建:待办页面 + router/ + index.ts # 修改:添加 /todo 路由 + components/ + SidebarNav.vue # 修改:添加待办菜单 +``` + +--- + +## Task 1: 后端 - 数据模型 + +**Files:** +- Create: `backend/app/models/todo.py` +- Modify: `backend/app/models/__init__.py` + +- [ ] **Step 1: 创建 `app/models/todo.py`** + +参考现有 `app/models/task.py` 的写法: + +```python +from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum +from datetime import datetime +from enum import Enum as PyEnum +from app.models.base import BaseModel + + +class TodoSource(str, PyEnum): + AI_KANBAN = "ai_kanban" + AI_CHAT = "ai_chat" + MANUAL = "manual" + + +class DailyTodo(BaseModel): + __tablename__ = "daily_todos" + + user_id = Column(String(36), nullable=False, index=True) + title = Column(String(500), nullable=False) + is_completed = Column(Boolean, default=False, nullable=False) + source = Column(SQLEnum(TodoSource), default=TodoSource.MANUAL, nullable=False) + source_detail = Column(String(500), nullable=True) + source_ref_id = Column(String(36), nullable=True) + todo_date = Column(String(10), nullable=False) # YYYY-MM-DD + completed_at = Column(DateTime, nullable=True) +``` + +- [ ] **Step 2: 修改 `app/models/__init__.py`,添加导出** + +在文件末尾添加: +```python +from app.models.todo import DailyTodo +__all__ = [..., "DailyTodo"] +``` + +- [ ] **Step 3: 提交** + +```bash +git add backend/app/models/todo.py backend/app/models/__init__.py +git commit -m "feat(todo): add DailyTodo model" +``` + +--- + +## Task 2: 后端 - Schema 定义 + +**Files:** +- Create: `backend/app/schemas/todo.py` + +- [ ] **Step 1: 创建 `app/schemas/todo.py`** + +```python +from pydantic import BaseModel +from datetime import datetime +from app.models.todo import TodoSource + + +class TodoCreate(BaseModel): + title: str + + +class TodoUpdate(BaseModel): + title: str | None = None + is_completed: bool | None = None + + +class TodoOut(BaseModel): + id: str + title: str + is_completed: bool + source: TodoSource + source_detail: str | None + todo_date: str + completed_at: datetime | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class TodoListOut(BaseModel): + items: list[TodoOut] + total: int + page: int + page_size: int + + +class TodoSummaryOut(BaseModel): + date: str + total: int + completed: int + pending: int +``` + +- [ ] **Step 2: 提交** + +```bash +git add backend/app/schemas/todo.py +git commit -m "feat(todo): add Pydantic schemas" +``` + +--- + +## Task 3: 后端 - API Router + +**Files:** +- Create: `backend/app/routers/todo.py` + +- [ ] **Step 1: 创建 `app/routers/todo.py`** + +完整实现以下端点: + +```python +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from datetime import date +from app.database import get_db +from app.models.todo import DailyTodo, TodoSource +from app.models.user import User +from app.routers.auth import get_current_user +from app.schemas.todo import ( + TodoCreate, TodoUpdate, TodoOut, TodoListOut, TodoSummaryOut +) +from app.services.todo_service import generate_daily_todos + +router = APIRouter(prefix="/api/todos", tags=["待办"]) + + +@router.get("", response_model=TodoListOut) +async def list_todos( + date_str: str = Query(default=None), # YYYY-MM-DD,默认当天 + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + target_date = date_str or date.today().isoformat() + offset = (page - 1) * page_size + + # 查询总数 + count_q = select(func.count()).select_from(DailyTodo).where( + DailyTodo.user_id == current_user.id, + DailyTodo.todo_date == target_date, + ) + total = (await db.execute(count_q)).scalar() + + # 查询列表 + q = select(DailyTodo).where( + DailyTodo.user_id == current_user.id, + DailyTodo.todo_date == target_date, + ).order_by(DailyTodo.created_at.desc()).offset(offset).limit(page_size) + + items = (await db.execute(q)).scalars().all() + return TodoListOut(items=items, total=total, page=page, page_size=page_size) + + +@router.post("", response_model=TodoOut, status_code=201) +async def create_todo( + data: TodoCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + todo = DailyTodo( + user_id=current_user.id, + title=data.title, + source=TodoSource.MANUAL, + todo_date=date.today().isoformat(), + ) + db.add(todo) + await db.commit() + await db.refresh(todo) + return todo + + +@router.patch("/{todo_id}", response_model=TodoOut) +async def update_todo( + todo_id: str, + data: TodoUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(DailyTodo).where(DailyTodo.id == todo_id, DailyTodo.user_id == current_user.id) + ) + todo = result.scalar_one_or_none() + if not todo: + raise HTTPException(status_code=404, detail="待办不存在") + + # 历史日期不允许修改 + if todo.todo_date != date.today().isoformat(): + raise HTTPException(status_code=403, detail="历史待办不可修改") + + if data.title is not None: + todo.title = data.title + if data.is_completed is not None: + from datetime import datetime + todo.is_completed = data.is_completed + todo.completed_at = datetime.utcnow() if data.is_completed else None + + await db.commit() + await db.refresh(todo) + return todo + + +@router.delete("/{todo_id}", status_code=204) +async def delete_todo( + todo_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(DailyTodo).where(DailyTodo.id == todo_id, DailyTodo.user_id == current_user.id) + ) + todo = result.scalar_one_or_none() + if not todo: + raise HTTPException(status_code=404, detail="待办不存在") + if todo.todo_date != date.today().isoformat(): + raise HTTPException(status_code=403, detail="历史待办不可删除") + + await db.delete(todo) + await db.commit() + + +@router.post("/ai-generate", response_model=TodoListOut) +async def ai_generate_todos( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + target_date = date.today().isoformat() + + # 幂等检查:是否已有AI生成记录 + check_q = select(func.count()).select_from(DailyTodo).where( + DailyTodo.user_id == current_user.id, + DailyTodo.todo_date == target_date, + DailyTodo.source.in_([TodoSource.AI_KANBAN, TodoSource.AI_CHAT]), + ) + count = (await db.execute(check_q)).scalar() + + if count > 0: + # 已生成,返回现有记录 + q = select(DailyTodo).where( + DailyTodo.user_id == current_user.id, + DailyTodo.todo_date == target_date, + ).order_by(DailyTodo.created_at.desc()) + items = (await db.execute(q)).scalars().all() + return TodoListOut(items=items, total=len(items), page=1, page_size=50) + + # 执行AI生成 + todos = await generate_daily_todos(current_user.id, db) + return TodoListOut(items=todos, total=len(todos), page=1, page_size=50) + + +@router.get("/summary", response_model=TodoSummaryOut) +async def get_summary( + date_str: str = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + target_date = date_str or date.today().isoformat() + q = select(DailyTodo).where( + DailyTodo.user_id == current_user.id, + DailyTodo.todo_date == target_date, + ) + todos = (await db.execute(q)).scalars().all() + completed = sum(1 for t in todos if t.is_completed) + return TodoSummaryOut(date=target_date, total=len(todos), completed=completed, pending=len(todos) - completed) +``` + +- [ ] **Step 2: 提交** + +```bash +git add backend/app/routers/todo.py +git commit -m "feat(todo): add API router with CRUD + ai-generate" +``` + +--- + +## Task 4: 后端 - AI 生成逻辑 Service + +**Files:** +- Create: `backend/app/services/todo_service.py` + +- [ ] **Step 1: 创建 `app/services/todo_service.py`** + +```python +import json +import logging +from datetime import date, datetime, timedelta +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.models.todo import DailyTodo, TodoSource +from app.models.task import Task, TaskStatus +from app.models.conversation import Conversation, Message +from app.services.llm_service import llm_service + +logger = logging.getLogger(__name__) + + +async def generate_daily_todos(user_id: str, db: AsyncSession) -> list[DailyTodo]: + """ + 为用户生成今日待办: + 1. 来自前一天未完成的看板任务(最多20条) + 2. 来自前一天对话记录分析(最多3条) + """ + today = date.today() + yesterday = (today - timedelta(days=1)).isoformat() + + todos: list[DailyTodo] = [] + + # 1. 从看板任务导入 + kanban_todos = await _import_kanban_tasks(user_id, yesterday, db) + todos.extend(kanban_todos) + + # 2. 从对话记录分析 + chat_todos = await _analyze_chat_history(user_id, yesterday, db) + todos.extend(chat_todos) + + return todos + + +async def _import_kanban_tasks(user_id: str, date_str: str, db: AsyncSession) -> list[DailyTodo]: + """导入前一天创建的、未完成的看板任务""" + q = select(Task).where( + Task.user_id == user_id, + Task.status != TaskStatus.DONE, + ).order_by(Task.created_at.desc()).limit(20) + + tasks = (await db.execute(q)).scalars().all() + todos = [] + + for task in tasks: + todo = DailyTodo( + user_id=user_id, + title=task.title, + source=TodoSource.AI_KANBAN, + source_detail=f"看板:{task.title}", + source_ref_id=task.id, + todo_date=date.today().isoformat(), + ) + db.add(todo) + todos.append(todo) + + if todos: + await db.commit() + for todo in todos: + await db.refresh(todo) + + return todos + + +async def _analyze_chat_history(user_id: str, date_str: str, db: AsyncSession) -> list[DailyTodo]: + """分析前一天对话,提取待办事项""" + try: + # 查询前一天创建的对话 + conv_q = select(Conversation).where( + Conversation.user_id == user_id, + ).order_by(Conversation.created_at.desc()).limit(10) + convs = (await db.execute(conv_q)).scalars().all() + + # 过滤出昨天的对话 + yesterday_convs = [] + for conv in convs: + created = conv.created_at + if hasattr(created, 'date'): + created_date = created.date() if hasattr(created, 'date') else created + else: + created_date = datetime.fromisoformat(str(created)).date() + + if str(created_date) == date_str or (created + timedelta(hours=8)).strftime('%Y-%m-%d') == date_str: + yesterday_convs.append(conv) + + if not yesterday_convs: + return [] + + # 收集消息内容(限制2000字) + messages_content = [] + for conv in yesterday_convs: + msg_q = select(Message).where( + Message.conversation_id == conv.id + ).order_by(Message.created_at.asc()).limit(50) + msgs = (await db.execute(msg_q)).scalars().all() + for msg in msgs: + if msg.content: + messages_content.append(f"[{msg.role}]: {msg.content[:500]}") + + if not messages_content: + return [] + + full_text = "\n".join(messages_content)[:2000] + + # 调用 LLM 分析 + prompt = f"""你是一个任务规划助手。请分析以下对话记录,提取其中用户想要完成但尚未明确完成的事项。 + +要求: +- 最多提取 3 条 +- 每条格式:{{"title": "事项描述(50字以内)", "reason": "来源说明(60字以内)"}} +- 只提取用户明确表达过需求但还未完成的事项 +- 如果没有可提取的内容,返回空数组 [] + +对话记录: +{full_text} + +返回 JSON 数组:""" + + response = await llm_service.chat(prompt=prompt, system=None) + content = response if isinstance(response, str) else (response.get("content") or "") + + # 尝试解析 JSON + try: + # 提取 JSON 数组 + start = content.find('[') + end = content.rfind(']') + 1 + if start != -1 and end > start: + items = json.loads(content[start:end]) + else: + items = [] + except (json.JSONDecodeError, ValueError): + logger.warning(f"LLM 返回格式异常,跳过对话分析: {content[:200]}") + items = [] + + if not items: + return [] + + todos = [] + for item in items[:3]: + todo = DailyTodo( + user_id=user_id, + title=item.get("title", "")[:500], + source=TodoSource.AI_CHAT, + source_detail=f"对话:{item.get('reason', '')[:60]}", + todo_date=date.today().isoformat(), + ) + db.add(todo) + todos.append(todo) + + if todos: + await db.commit() + for todo in todos: + await db.refresh(todo) + + return todos + + except Exception as e: + logger.error(f"对话分析失败: {e}") + return [] +``` + +- [ ] **Step 2: 提交** + +```bash +git add backend/app/services/todo_service.py +git commit -m "feat(todo): add AI generation service" +``` + +--- + +## Task 5: 后端 - 注册路由 + +**Files:** +- Modify: `backend/app/main.py` + +- [ ] **Step 1: 在 `main.py` 中添加 todo router** + +找到其他 router 注册的位置(如 `app/routers/conversation` 等),添加: +```python +from app.routers.todo import router as todo_router +app.include_router(todo_router) +``` + +- [ ] **Step 2: 提交** + +```bash +git add backend/app/main.py +git commit -m "feat(todo): register todo router in main" +``` + +--- + +## Task 6: 后端 - 定时任务(APScheduler) + +**Files:** +- Modify: `backend/app/routers/scheduler.py` +- Create: `backend/app/services/scheduler_service.py` (或修改现有) + +- [ ] **Step 1: 查看现有的 scheduler.py 结构** + +读取 `backend/app/routers/scheduler.py` 和 `backend/app/services/scheduler_service.py`,了解现有定时任务模式。 + +- [ ] **Step 2: 添加每日 AI 生成定时任务** + +在 scheduler_service 中添加: +```python +async def daily_todo_generation(): + """每天早上8点为所有活跃用户生成待办""" + from app.database import async_session + from app.models.user import User + from sqlalchemy import select + from app.services.todo_service import generate_daily_todos + + async with async_session() as db: + result = await db.execute(select(User).where(User.is_active == True)) + users = result.scalars().all() + for user in users: + try: + await generate_daily_todos(user.id, db) + except Exception as e: + logger.error(f"用户 {user.id} 定时生成待办失败: {e}") +``` + +在 APScheduler 中注册: +```python +scheduler.add_job( + daily_todo_generation, + "cron", + hour=8, minute=0, + id="daily_todo_generation", + replace_existing=True, +) +``` + +- [ ] **Step 3: 提交** + +```bash +git add backend/app/services/scheduler_service.py +git commit -m "feat(todo): add daily AI todo generation scheduler" +``` + +--- + +## Task 7: 前端 - API 客户端 + +**Files:** +- Create: `frontend/src/api/todo.ts` + +- [ ] **Step 1: 创建 `src/api/todo.ts`** + +参考 `src/api/task.ts` 的模式: + +```typescript +import api from './index' + +export type TodoSource = 'ai_kanban' | 'ai_chat' | 'manual' + +export interface Todo { + id: string + title: string + is_completed: boolean + source: TodoSource + source_detail: string | null + todo_date: string + completed_at: string | null + created_at: string + updated_at: string +} + +export interface TodoListResponse { + items: Todo[] + total: number + page: number + page_size: number +} + +export interface TodoSummary { + date: string + total: number + completed: number + pending: number +} + +export const todoApi = { + list(date?: string, page = 1, pageSize = 50) { + return api.get('/api/todos', { + params: date ? { date_str: date, page, page_size: pageSize } : { page, page_size: pageSize }, + }) + }, + + create(title: string) { + return api.post('/api/todos', { title }) + }, + + update(id: string, data: { title?: string; is_completed?: boolean }) { + return api.patch(`/api/todos/${id}`, data) + }, + + delete(id: string) { + return api.delete(`/api/todos/${id}`) + }, + + aiGenerate() { + return api.post('/api/todos/ai-generate') + }, + + summary(date?: string) { + return api.get('/api/todos/summary', { + params: date ? { date_str: date } : {}, + }) + }, +} +``` + +- [ ] **Step 2: 提交** + +```bash +git add frontend/src/api/todo.ts +git commit -m "feat(todo): add todo API client" +``` + +--- + +## Task 8: 前端 - 待办页面 TodoView.vue + +**Files:** +- Create: `frontend/src/views/TodoView.vue` + +- [ ] **Step 1: 创建 TodoView.vue** + +页面布局:顶部日期导航 + 待办列表,参考 AgentView 的 sci-fi 风格 + +核心结构: +```vue + + + + + +``` + +- [ ] **Step 2: 提交** + +```bash +git add frontend/src/views/TodoView.vue +git commit -m "feat(todo): add TodoView page" +``` + +--- + +## Task 9: 前端 - 路由和侧边栏 + +**Files:** +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/components/SidebarNav.vue` + +- [ ] **Step 1: 在 `router/index.ts` 中添加 /todo 路由** + +在 children 数组中添加: +```typescript +{ + path: 'todo', + name: 'todo', + component: () => import('@/views/TodoView.vue'), +}, +``` + +- [ ] **Step 2: 在 `SidebarNav.vue` 中添加待办菜单项** + +在 navItems 数组中添加: +```typescript +{ name: '待办', path: '/todo', icon: CheckSquare }, +``` + +导入 CheckSquare: +```typescript +import { CheckSquare } from 'lucide-vue-next' +``` + +- [ ] **Step 3: 提交** + +```bash +git add frontend/src/router/index.ts frontend/src/components/SidebarNav.vue +git commit -m "feat(todo): add route and sidebar menu" +``` + +--- + +## Task 10: 数据库迁移 + +**Files:** +- 创建迁移脚本(手动 SQL 或 Alembic) + +- [ ] **Step 1: 创建数据库表** + +直接执行 SQL(参考 `app/models/todo.py` 的定义): + +```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 FALSE, + 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, + INDEX idx_user_date (user_id, todo_date), + INDEX idx_user_id (user_id) +); +``` + +使用 sqlite3 执行(假设数据库文件在 backend 目录): +```bash +cd backend +sqlite3 myagents.db < create_daily_todos.sql +``` + +或通过 Python 直接创建表: +```python +# 在 backend 目录执行 +python -c " +import asyncio +from app.database import engine, BaseModel +from app.models.todo import DailyTodo +from sqlalchemy import text + +async def create_table(): + async with engine.begin() as conn: + await conn.execute(text(''' + 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 + ) + ''')) + await conn.execute(text('CREATE INDEX IF NOT EXISTS idx_daily_todos_user_date ON daily_todos(user_id, todo_date)')) + await conn.execute(text('CREATE INDEX IF NOT EXISTS idx_daily_todos_user_id ON daily_todos(user_id)')) + print('Table created successfully') + +asyncio.run(create_table()) +" +``` + +- [ ] **Step 2: 提交迁移脚本** + +```bash +git add docs/superpowers/plans/2026-03-20-daily-todo-migration.sql +git commit -m "feat(todo): add database migration" +``` + +--- + +## 验证清单 + +完成所有 Task 后,验证以下内容: + +1. **后端 API 可访问**:`GET /api/todos` 返回 200 +2. **CRUD 正常**:新增、修改、删除待办均正常 +3. **AI 生成正常**:`POST /api/todos/ai-generate` 返回待办列表 +4. **前端页面正常**:`/todo` 页面可访问 +5. **侧边栏菜单**:待办菜单项显示 +6. **样式正常**:页面风格与 AgentView 一致(sci-fi 全息终端) +7. **日期切换正常**:昨天/前天数据正确加载 +8. **历史只读**:修改历史日期的待办返回 403 + +--- + +## 实现顺序建议 + +1. Task 1 → 2 → 3 → 5(后端核心) +2. Task 4(AI Service) +3. Task 6(定时任务) +4. Task 10(数据库) +5. Task 7 → 8 → 9(前端) diff --git a/docs/superpowers/plans/2026-03-20-langsmith-integration.md b/docs/superpowers/plans/2026-03-20-langsmith-integration.md new file mode 100644 index 0000000..72e9745 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-langsmith-integration.md @@ -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 消耗统计 diff --git a/docs/superpowers/plans/2026-03-20-settings-register-plan.md b/docs/superpowers/plans/2026-03-20-settings-register-plan.md new file mode 100644 index 0000000..04164f9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-settings-register-plan.md @@ -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('/api/settings') + }, + + // 更新资料 + updateProfile(data: ProfileUpdate) { + return api.put('/api/settings/profile', data) + }, + + // 更新 LLM 配置 + updateLLM(config: Partial) { + return api.put('/api/settings/llm', config) + }, + + // 测试 LLM 连接 + testLLM(data: { type: LLMType } & LLMModelConfig) { + return api.post('/api/settings/llm/test', data) + }, + + // 更新定时任务配置 + updateScheduler(config: Partial) { + 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 + + + +``` + +样式部分复用 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 6(LoginView 注册功能) +4. Task 7(SettingsView) +5. Task 8(路由和侧边栏) +6. Task 9(数据库迁移) diff --git a/docs/superpowers/plans/2026-03-20-stats-dashboard-implementation.md b/docs/superpowers/plans/2026-03-20-stats-dashboard-implementation.md new file mode 100644 index 0000000..bceef13 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-stats-dashboard-implementation.md @@ -0,0 +1,1010 @@ +# 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:** 重新设计 StatsView.vue 数据统计页面,采用赛博朋克风格的迷你图表布局 + +**Architecture:** 单页垂直滚动布局,6个模块垂直排列,每个模块包含汇总数字卡片和迷你趋势图。复用现有 CSS 变量和 ChatView.vue 的设计模式。使用纯 CSS 实现迷你图表(条形图/折线图),避免引入新的图表依赖。 + +**Tech Stack:** Vue 3 + TypeScript + Pinia + lucide-vue-next(已有)+ echarts(已有,但优先用CSS实现) + +--- + +## File Structure + +``` +frontend/src/ +├── views/ +│ └── StatsView.vue # 完全重写 +├── components/stats/ +│ ├── MetricCard.vue # 新建 - 指标卡片组件 +│ ├── MiniLineChart.vue # 新建 - 迷你折线图 +│ ├── MiniBarChart.vue # 新建 - 迷你柱状图 +│ ├── SectionHeader.vue # 新建 - 区块标题 +│ └── SummaryRow.vue # 新建 - 汇总行 +└── style.css # 可能需要新增CSS变量 +``` + +--- + +## Task 1: 创建 SectionHeader 组件 + +**Files:** +- Create: `frontend/src/components/stats/SectionHeader.vue` +- Modify: `frontend/src/views/StatsView.vue`(引入组件) + +- [ ] **Step 1: 创建 SectionHeader.vue** + +```vue + + + + + +``` + +- [ ] **Step 2: 在 StatsView.vue 中引入并使用 SectionHeader** + +在 ` + + + + +``` + +--- + +## Task 3: 创建 SummaryRow 组件 + +**Files:** +- Create: `frontend/src/components/stats/SummaryRow.vue` + +- [ ] **Step 1: 创建 SummaryRow.vue** + +```vue + + + + + +``` + +--- + +## Task 4: 创建 MiniBarChart 组件(CSS实现) + +**Files:** +- Create: `frontend/src/components/stats/MiniBarChart.vue` + +- [ ] **Step 1: 创建 MiniBarChart.vue** + +```vue + + + + + +``` + +--- + +## Task 5: 创建 MiniLineChart 组件(CSS实现) + +**Files:** +- Create: `frontend/src/components/stats/MiniLineChart.vue` + +- [ ] **Step 1: 创建 MiniLineChart.vue** + +```vue + + + + + +``` + +--- + +## Task 6: 重写 StatsView.vue + +**Files:** +- Modify: `frontend/src/views/StatsView.vue` + +- [ ] **Step 1: 重写 script setup 部分** + +```typescript +import { ref, onMounted, computed } from 'vue' +import * as statsApi from '@/api/stats' +import { Cpu, HardDrive, MemoryStick, Clock, MessageSquare, BookOpen, CheckSquare, TrendingUp, Tag } from 'lucide-vue-next' +import SectionHeader from '@/components/stats/SectionHeader.vue' +import MetricCard from '@/components/stats/MetricCard.vue' +import SummaryRow from '@/components/stats/SummaryRow.vue' +import MiniLineChart from '@/components/stats/MiniLineChart.vue' +import MiniBarChart from '@/components/stats/MiniBarChart.vue' + +const isLoading = ref(true) +const hasError = ref(false) + +// 数据状态 +const systemHealth = ref(null) +const conversationStats = ref(null) +const knowledgeStats = ref(null) +const kanbanStats = ref(null) +const communityStats = ref(null) +const personalInsights = ref(null) + +function formatUptime(seconds: number) { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const mins = Math.floor((seconds % 3600) / 60) + if (days > 0) return `${days}d ${hours}h` + if (hours > 0) return `${hours}h ${mins}m` + return `${mins}m` +} + +function formatNumber(num: number): string { + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M' + if (num >= 1000) return (num / 1000).toFixed(1) + 'K' + return num.toString() +} + +onMounted(async () => { + try { + const promises = [ + statsApi.getSystemHealth().catch(() => null), + ] + const [sys] = await Promise.all(promises) + systemHealth.value = sys?.data || null + + // 尝试加载用户相关数据(需要认证) + const userPromises = [ + statsApi.getConversationStats().catch(() => null), + statsApi.getKnowledgeStats().catch(() => null), + statsApi.getKanbanStats().catch(() => null), + statsApi.getCommunityStats().catch(() => null), + statsApi.getPersonalInsights().catch(() => null), + ] + const [conv, know, kanban, community, insights] = await Promise.all(userPromises) + conversationStats.value = conv?.data || null + knowledgeStats.value = know?.data || null + kanbanStats.value = kanban?.data || null + communityStats.value = community?.data || null + personalInsights.value = insights?.data || null + } catch (e) { + hasError.value = true + console.error('Failed to load stats:', e) + } finally { + isLoading.value = false + } +}) + +// 图表数据转换 +const convChartData = computed(() => + conversationStats.value?.daily_conversations?.map((d: any) => ({ date: d.date, value: d.count })) || [] +) +const msgChartData = computed(() => + conversationStats.value?.daily_messages?.map((d: any) => ({ date: d.date, value: d.count })) || [] +) +const inputTokenData = computed(() => + conversationStats.value?.daily_input_tokens?.map((d: any) => ({ date: d.date, value: d.input_tokens })) || [] +) +const outputTokenData = computed(() => + conversationStats.value?.daily_output_tokens?.map((d: any) => ({ date: d.date, value: d.output_tokens })) || [] +) + +const knowChartData = computed(() => + knowledgeStats.value?.daily_new_tags?.map((d: any) => ({ date: d.date, value: d.count })) || [] +) + +const kanbanNewData = computed(() => + kanbanStats.value?.daily_new_tasks?.map((d: any) => d.count) || [] +) +const kanbanDoneData = computed(() => + kanbanStats.value?.daily_completed_tasks?.map((d: any) => d.count) || [] +) + +const communityChartData = computed(() => + communityStats.value?.daily_posts?.map((d: any) => ({ date: d.date, value: d.count })) || [] +) + +const hourlyActivityData = computed(() => + personalInsights.value?.hourly_activity?.map((h: any) => h.count) || [] +) +``` + +- [ ] **Step 2: 重写 template 部分** + +```vue + +``` + +- [ ] **Step 3: 重写 style 部分** + +```vue + +``` + +--- + +## Task 7: 添加缺少的 CSS 变量(如需要) + +**Files:** +- Modify: `frontend/src/style.css` + +- [ ] **Step 1: 检查并添加缺失的 CSS 变量** + +如果 `--accent-purple` 不存在,添加: +```css +--accent-purple: #a855f7; +--accent-purple-dim: rgba(123, 44, 191, 0.15); +``` + +--- + +## Task 8: 验证与测试 + +**Files:** +- Test: `frontend/src/views/StatsView.vue` +- Test: `frontend/src/components/stats/*.vue` + +- [ ] **Step 1: 运行 TypeScript 检查** + +```bash +cd frontend && npx vue-tsc --noEmit +``` + +- [ ] **Step 2: 运行开发服务器测试** + +```bash +cd frontend && npm run dev +``` + +- [ ] **Step 3: 验证页面渲染** +- 打开 http://localhost:5173/stats +- 确认无 console errors +- 确认页面布局正确 + +--- + +## 执行选项 + +**1. Subagent-Driven (推荐)** - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代 + +**2. Inline Execution** - 在当前会话中按批次执行任务 + +选择哪种方式? diff --git a/docs/superpowers/plans/2026-03-20-stats-dashboard.md b/docs/superpowers/plans/2026-03-20-stats-dashboard.md new file mode 100644 index 0000000..9c97b63 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-stats-dashboard.md @@ -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 + + + + + +``` + +--- + +## 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` | diff --git a/docs/superpowers/plans/2026-03-20-tag-system.md b/docs/superpowers/plans/2026-03-20-tag-system.md new file mode 100644 index 0000000..2e202e6 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-tag-system.md @@ -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:** 实现一个层级标签系统,标签作为 KGNode(entity_type="tag"),支持 AI 自动生成标签、标签关系网络、内容关联发现。 + +**Architecture:** +- 标签存储为 KGNode(entity_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` | diff --git a/docs/superpowers/plans/2026-03-21-forum-redesign-implementation.md b/docs/superpowers/plans/2026-03-21-forum-redesign-implementation.md new file mode 100644 index 0000000..e659d13 --- /dev/null +++ b/docs/superpowers/plans/2026-03-21-forum-redesign-implementation.md @@ -0,0 +1,1611 @@ +# 交互广场重新设计实现计划 + +> **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驱动的智能板块:AI学习、AI建议、AI交互 + +**Architecture:** 前端三板块布局,后端三个Service处理业务逻辑,数据库新增三张表存储学习记录、建议和交互主题 + +**Tech Stack:** Vue 3 + TypeScript + FastAPI + SQLAlchemy + LLM + +--- + +## 文件结构 + +``` +backend/app/ +├── models/ +│ ├── learning_record.py # 新建 - 学习记录模型 +│ ├── suggestion.py # 新建 - 建议模型 +│ └── interactive_topic.py # 新建 - 交互主题模型 +├── schemas/ +│ ├── learning.py # 新建 - LearningRecord Pydantic schemas +│ ├── suggestion.py # 新建 - Suggestion Pydantic schemas +│ └── interactive.py # 新建 - InteractiveTopic Pydantic schemas +├── services/ +│ ├── learning_service.py # 新建 - AI学习服务 +│ ├── suggestion_service.py # 新建 - AI建议服务 +│ └── interactive_service.py # 新建 - AI交互服务 +└── routers/ + └── forum.py # 修改 - 添加新接口 + +frontend/src/ +├── api/ +│ └── forum.ts # 修改 - 添加新API方法 +├── views/ +│ └── ForumView.vue # 修改 - 重写为三板块布局 +└── components/forum/ + ├── LearningSection.vue # 新建 - AI学习板块 + │ ├── LearningSummaryCard.vue # 新建 - 今日摘要卡片 + │ ├── LearningTimeline.vue # 新建 - 学习历史时间线 + │ └── LearningStats.vue # 新建 - 图谱更新统计 + ├── SuggestionSection.vue # 新建 - AI建议板块 + │ └── SuggestionCard.vue # 新建 - 建议卡片 + └── InteractiveSection.vue # 新建 - AI交互板块 + └── LearningInput.vue # 新建 - 学习主题输入框 +``` + +--- + +## Task 1: 创建 LearningRecord 模型 + +**Files:** +- Create: `backend/app/models/learning_record.py` + +- [ ] **Step 1: 创建 learning_record.py** + +```python +from sqlalchemy import Column, String, Text, ForeignKey, JSON +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列表 + kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表 +``` + +- [ ] **Step 2: 在 models/__init__.py 中导出** + +```python +from app.models.learning_record import LearningRecord +``` + +--- + +## Task 2: 创建 Suggestion 模型 + +**Files:** +- Create: `backend/app/models/suggestion.py` + +- [ ] **Step 1: 创建 suggestion.py** + +```python +from sqlalchemy import Column, String, Text, ForeignKey, JSON, Boolean +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) # 分析依据 + is_read = Column(Boolean, default=False) # 是否已读 + is_dismissed = Column(Boolean, default=False) # 是否忽略 +``` + +- [ ] **Step 2: 在 models/__init__.py 中导出** + +```python +from app.models.suggestion import Suggestion +``` + +--- + +## Task 3: 创建 InteractiveTopic 模型 + +**Files:** +- Create: `backend/app/models/interactive_topic.py` + +- [ ] **Step 1: 创建 interactive_topic.py** + +```python +from sqlalchemy import Column, String, Text, ForeignKey, JSON, DateTime +from sqlalchemy.orm import relationship +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) +``` + +- [ ] **Step 2: 在 models/__init__.py 中导出** + +```python +from app.models.interactive_topic import InteractiveTopic +``` + +--- + +## Task 4: 创建 Pydantic Schemas + +**Files:** +- Create: `backend/app/schemas/learning.py` +- Create: `backend/app/schemas/suggestion.py` +- Create: `backend/app/schemas/interactive.py` + +- [ ] **Step 1: 创建 learning.py** + +```python +from pydantic import BaseModel +from typing import Optional + + +class LearningRecordOut(BaseModel): + id: str + learning_type: str + topic: str + summary: str + source: str + source_ids: Optional[dict] = None + kg_nodes_created: Optional[list[str]] = None + created_at: str + + model_config = {"from_attributes": True} + + +class LearningSummaryOut(BaseModel): + summary: str + records: list[LearningRecordOut] + stats: dict + + +class LearningHistoryOut(BaseModel): + records: list[LearningRecordOut] + total: int +``` + +- [ ] **Step 2: 创建 suggestion.py** + +```python +from pydantic import BaseModel +from typing import Optional + + +class SuggestionOut(BaseModel): + id: str + suggestion_type: str + title: str + content: str + source_data: Optional[dict] = None + is_read: bool + is_dismissed: bool + created_at: str + + model_config = {"from_attributes": True} + + +class SuggestionListOut(BaseModel): + suggestions: list[SuggestionOut] +``` + +- [ ] **Step 3: 创建 interactive.py** + +```python +from pydantic import BaseModel +from typing import Optional + + +class InteractiveTopicOut(BaseModel): + id: str + topic: str + status: str + result: Optional[str] = None + kg_nodes_created: Optional[list[str]] = None + source: str + created_at: str + completed_at: Optional[str] = None + + model_config = {"from_attributes": True} + + +class InteractiveTopicsOut(BaseModel): + user_initiated: list[InteractiveTopicOut] + ai_proactive: list[InteractiveTopicOut] + + +class LearnRequest(BaseModel): + topic: str + + +class LearnResponse(BaseModel): + topic_id: str + status: str +``` + +--- + +## Task 5: 创建 LearningService + +**Files:** +- Create: `backend/app/services/learning_service.py` + +- [ ] **Step 1: 创建 learning_service.py** + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc +from app.models.learning_record import LearningRecord +from app.models.conversation import Message +from app.models.task import Task +from app.models.knowledge_graph import KGNode +from app.core.llm import get_llm_client +from datetime import datetime, timedelta +from typing import Optional + + +class LearningService: + def __init__(self, db: AsyncSession): + self.db = db + self.llm = get_llm_client() + + async def get_summary(self, user_id: str) -> dict: + """获取今日学习摘要""" + today = datetime.utcnow().date() + today_start = datetime.combine(today, datetime.min.time()) + + result = await self.db.execute( + select(LearningRecord) + .where( + LearningRecord.user_id == user_id, + LearningRecord.created_at >= today_start + ) + .order_by(desc(LearningRecord.created_at)) + ) + records = result.scalars().all() + + if not records: + return { + "summary": "今日暂无学习记录", + "records": [], + "stats": {"nodes_created": 0, "edges_created": 0} + } + + # 生成摘要 + topics = [r.topic for r in records] + summary = f"今日学习了 {len(topics)} 个主题:{', '.join(topics[:3])}" + + # 统计 + nodes_count = sum(len(r.kg_nodes_created or []) for r in records) + + return { + "summary": summary, + "records": [self._record_to_dict(r) for r in records], + "stats": {"nodes_created": nodes_count, "edges_created": 0} + } + + async def get_history(self, user_id: str, page: int = 1, limit: int = 20) -> dict: + """获取学习历史""" + offset = (page - 1) * limit + + result = await self.db.execute( + select(LearningRecord) + .where(LearningRecord.user_id == user_id) + .order_by(desc(LearningRecord.created_at)) + .limit(limit) + .offset(offset) + ) + records = result.scalars().all() + + count_result = await self.db.execute( + select(LearningRecord) + .where(LearningRecord.user_id == user_id) + ) + total = len(count_result.scalars().all()) + + return { + "records": [self._record_to_dict(r) for r in records], + "total": total + } + + def _record_to_dict(self, record: LearningRecord) -> dict: + return { + "id": record.id, + "learning_type": record.learning_type, + "topic": record.topic, + "summary": record.summary, + "source": record.source, + "source_ids": record.source_ids, + "kg_nodes_created": record.kg_nodes_created, + "created_at": record.created_at.isoformat() if record.created_at else None + } +``` + +--- + +## Task 6: 创建 SuggestionService + +**Files:** +- Create: `backend/app/services/suggestion_service.py` + +- [ ] **Step 1: 创建 suggestion_service.py** + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.models.suggestion import Suggestion +from app.models.knowledge_graph import KGNode +from app.core.llm import get_llm_client +from datetime import datetime + + +class SuggestionService: + def __init__(self, db: AsyncSession): + self.db = db + self.llm = get_llm_client() + + async def get_suggestions(self, user_id: str) -> list[dict]: + """获取用户的所有建议""" + result = await self.db.execute( + select(Suggestion) + .where( + Suggestion.user_id == user_id, + Suggestion.is_dismissed == False + ) + .order_by(Suggestion.created_at.desc()) + ) + suggestions = result.scalars().all() + + return [self._suggestion_to_dict(s) for s in suggestions] + + async def get_suggestion(self, suggestion_id: str, user_id: str) -> dict: + """获取单个建议""" + result = await self.db.execute( + select(Suggestion).where( + Suggestion.id == suggestion_id, + Suggestion.user_id == user_id + ) + ) + suggestion = result.scalar_one_or_none() + if not suggestion: + return None + return self._suggestion_to_dict(suggestion) + + async def mark_read(self, suggestion_id: str, user_id: str) -> bool: + """标记建议为已读""" + result = await self.db.execute( + select(Suggestion).where( + Suggestion.id == suggestion_id, + Suggestion.user_id == user_id + ) + ) + suggestion = result.scalar_one_or_none() + if not suggestion: + return False + suggestion.is_read = True + await self.db.commit() + return True + + async def dismiss(self, suggestion_id: str, user_id: str) -> bool: + """忽略建议""" + result = await self.db.execute( + select(Suggestion).where( + Suggestion.id == suggestion_id, + Suggestion.user_id == user_id + ) + ) + suggestion = result.scalar_one_or_none() + if not suggestion: + return False + suggestion.is_dismissed = True + await self.db.commit() + return True + + def _suggestion_to_dict(self, suggestion: Suggestion) -> dict: + return { + "id": suggestion.id, + "suggestion_type": suggestion.suggestion_type, + "title": suggestion.title, + "content": suggestion.content, + "source_data": suggestion.source_data, + "is_read": suggestion.is_read, + "is_dismissed": suggestion.is_dismissed, + "created_at": suggestion.created_at.isoformat() if suggestion.created_at else None + } +``` + +--- + +## Task 7: 创建 InteractiveService + +**Files:** +- Create: `backend/app/services/interactive_service.py` + +- [ ] **Step 1: 创建 interactive_service.py** + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc +from app.models.interactive_topic import InteractiveTopic +from app.core.llm import get_llm_client +from datetime import datetime + + +class InteractiveService: + def __init__(self, db: AsyncSession): + self.db = db + self.llm = get_llm_client() + + async def get_topics(self, user_id: str) -> dict: + """获取交互主题列表""" + result = await self.db.execute( + select(InteractiveTopic) + .where(InteractiveTopic.user_id == user_id) + .order_by(desc(InteractiveTopic.created_at)) + ) + topics = result.scalars().all() + + user_initiated = [t for t in topics if t.source == "user_initiated"] + ai_proactive = [t for t in topics if t.source == "ai_proactive"] + + return { + "user_initiated": [self._topic_to_dict(t) for t in user_initiated], + "ai_proactive": [self._topic_to_dict(t) for t in ai_proactive] + } + + async def initiate_learning(self, user_id: str, topic: str) -> dict: + """用户发起学习""" + interactive_topic = InteractiveTopic( + user_id=user_id, + topic=topic, + status="pending", + source="user_initiated" + ) + self.db.add(interactive_topic) + await self.db.commit() + await self.db.refresh(interactive_topic) + + # 触发异步学习(实际实现中可能用后台任务) + # 这里简化为直接返回 + return self._topic_to_dict(interactive_topic) + + async def get_topic_detail(self, topic_id: str, user_id: str) -> dict: + """获取主题详情""" + result = await self.db.execute( + select(InteractiveTopic).where( + InteractiveTopic.id == topic_id, + InteractiveTopic.user_id == user_id + ) + ) + topic = result.scalar_one_or_none() + if not topic: + return None + return self._topic_to_dict(topic) + + def _topic_to_dict(self, topic: InteractiveTopic) -> dict: + return { + "id": topic.id, + "topic": topic.topic, + "status": topic.status, + "result": topic.result, + "kg_nodes_created": topic.kg_nodes_created, + "source": topic.source, + "created_at": topic.created_at.isoformat() if topic.created_at else None, + "completed_at": topic.completed_at.isoformat() if topic.completed_at else None + } +``` + +--- + +## Task 8: 修改 Forum Router + +**Files:** +- Modify: `backend/app/routers/forum.py` + +- [ ] **Step 1: 添加新接口** + +在文件开头添加导入: +```python +from app.schemas.learning import LearningSummaryOut, LearningHistoryOut +from app.schemas.suggestion import SuggestionOut, SuggestionListOut +from app.schemas.interactive import InteractiveTopicOut, InteractiveTopicsOut, LearnRequest, LearnResponse +from app.services.learning_service import LearningService +from app.services.suggestion_service import SuggestionService +from app.services.interactive_service import InteractiveService +``` + +- [ ] **Step 2: 添加 Learning 接口** + +```python +@router.get("/learning/summary", response_model=LearningSummaryOut) +async def get_learning_summary( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + service = LearningService(db) + return await service.get_summary(current_user.id) + + +@router.get("/learning/history", response_model=LearningHistoryOut) +async def get_learning_history( + page: int = 1, + limit: int = 20, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + service = LearningService(db) + return await service.get_history(current_user.id, page, limit) +``` + +- [ ] **Step 3: 添加 Suggestion 接口** + +```python +@router.get("/suggestions", response_model= dict) +async def list_suggestions( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + service = SuggestionService(db) + suggestions = await service.get_suggestions(current_user.id) + return {"suggestions": suggestions} + + +@router.get("/suggestions/{suggestion_id}", response_model=SuggestionOut) +async def get_suggestion( + suggestion_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + service = SuggestionService(db) + suggestion = await service.get_suggestion(suggestion_id, current_user.id) + if not suggestion: + raise HTTPException(status_code=404, detail="建议不存在") + return suggestion + + +@router.patch("/suggestions/{suggestion_id}/read") +async def mark_suggestion_read( + suggestion_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + service = SuggestionService(db) + success = await service.mark_read(suggestion_id, current_user.id) + if not success: + raise HTTPException(status_code=404, detail="建议不存在") + return {"status": "ok"} + + +@router.delete("/suggestions/{suggestion_id}/dismiss") +async def dismiss_suggestion( + suggestion_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + service = SuggestionService(db) + success = await service.dismiss(suggestion_id, current_user.id) + if not success: + raise HTTPException(status_code=404, detail="建议不存在") + return {"status": "ok"} +``` + +- [ ] **Step 4: 添加 Interactive 接口** + +```python +@router.get("/interactive/topics", response_model=InteractiveTopicsOut) +async def get_interactive_topics( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + service = InteractiveService(db) + return await service.get_topics(current_user.id) + + +@router.post("/interactive/learn", response_model=InteractiveTopicOut) +async def initiate_learning( + data: LearnRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + service = InteractiveService(db) + result = await service.initiate_learning(current_user.id, data.topic) + return result + + +@router.get("/interactive/topics/{topic_id}", response_model=InteractiveTopicOut) +async def get_interactive_topic( + topic_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + service = InteractiveService(db) + topic = await service.get_topic_detail(topic_id, current_user.id) + if not topic: + raise HTTPException(status_code=404, detail="主题不存在") + return topic +``` + +--- + +## Task 9: 更新前端 API + +**Files:** +- Modify: `frontend/src/api/forum.ts` + +- [ ] **Step 1: 添加新类型和API方法** + +```typescript +// Types +export 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 +} + +export interface LearningSummary { + summary: string + records: LearningRecord[] + stats: { + nodes_created: number + edges_created: number + } +} + +export interface Suggestion { + id: string + suggestion_type: 'knowledge' | 'efficiency' | 'skill' + title: string + content: string + source_data?: Record + is_read: boolean + is_dismissed: boolean + created_at: string +} + +export 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 methods +export const forumApi = { + // ... existing methods ... + + // Learning + fetchLearningSummary() { + return api.get('/api/forum/learning/summary') + }, + + fetchLearningHistory(params: { page: number, limit: number }) { + return api.get<{ records: LearningRecord[], total: number }>('/api/forum/learning/history', { params }) + }, + + // Suggestions + fetchSuggestions() { + return api.get<{ suggestions: Suggestion[] }>('/api/forum/suggestions') + }, + + getSuggestion(id: string) { + return api.get(`/api/forum/suggestions/${id}`) + }, + + markSuggestionRead(id: string) { + return api.patch(`/api/forum/suggestions/${id}/read`) + }, + + dismissSuggestion(id: string) { + return api.delete(`/api/forum/suggestions/${id}/dismiss`) + }, + + // Interactive + fetchInteractiveTopics() { + return api.get<{ user_initiated: InteractiveTopic[], ai_proactive: InteractiveTopic[] }>('/api/forum/interactive/topics') + }, + + initiateLearning(topic: string) { + return api.post('/api/forum/interactive/learn', { topic }) + }, + + getTopicDetail(id: string) { + return api.get(`/api/forum/interactive/topics/${id}`) + }, +} +``` + +--- + +## Task 10: 创建 LearningSection 组件 + +**Files:** +- Create: `frontend/src/components/forum/LearningSection.vue` +- Create: `frontend/src/components/forum/LearningSummaryCard.vue` +- Create: `frontend/src/components/forum/LearningTimeline.vue` +- Create: `frontend/src/components/forum/LearningStats.vue` + +- [ ] **Step 1: 创建 LearningSummaryCard.vue** + +```vue + + + + + +``` + +- [ ] **Step 2: 创建 LearningTimeline.vue** + +```vue + + + + + +``` + +- [ ] **Step 3: 创建 LearningStats.vue** + +```vue + + + + + +``` + +- [ ] **Step 4: 创建 LearningSection.vue** + +```vue + + + + + +``` + +--- + +## Task 11: 创建 SuggestionSection 组件 + +**Files:** +- Create: `frontend/src/components/forum/SuggestionSection.vue` +- Create: `frontend/src/components/forum/SuggestionCard.vue` + +- [ ] **Step 1: 创建 SuggestionCard.vue** + +```vue + + + + + +``` + +- [ ] **Step 2: 创建 SuggestionSection.vue** + +```vue + + + + + +``` + +--- + +## Task 12: 创建 InteractiveSection 组件 + +**Files:** +- Create: `frontend/src/components/forum/InteractiveSection.vue` +- Create: `frontend/src/components/forum/LearningInput.vue` + +- [ ] **Step 1: 创建 LearningInput.vue** + +```vue + + + + + +``` + +- [ ] **Step 2: 创建 InteractiveSection.vue** + +```vue + + + + + +``` + +--- + +## Task 13: 重写 ForumView.vue + +**Files:** +- Modify: `frontend/src/views/ForumView.vue` + +- [ ] **Step 1: 重写 ForumView.vue** + +```vue + + + + + +``` + +--- + +## Task 14: 验证和测试 + +- [ ] **Step 1: 后端语法检查** + +```bash +cd backend && python -m py_compile app/models/learning_record.py app/models/suggestion.py app/models/interactive_topic.py app/services/learning_service.py app/services/suggestion_service.py app/services/interactive_service.py app/routers/forum.py +``` + +- [ ] **Step 2: 前端 TypeScript 检查** + +```bash +cd frontend && npx vue-tsc --noEmit +``` + +- [ ] **Step 3: 启动服务测试** + +```bash +# 后端 +cd backend && python -m uvicorn app.main:app --reload + +# 前端 +cd frontend && npm run dev +``` + +--- + +## 执行选项 + +**1. Subagent-Driven (推荐)** - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代 + +**2. Inline Execution** - 在当前会话中按批次执行任务 + +选择哪种方式? diff --git a/docs/superpowers/plans/2026-03-21-knowledge-folder-plan.md b/docs/superpowers/plans/2026-03-21-knowledge-folder-plan.md new file mode 100644 index 0000000..1d4482e --- /dev/null +++ b/docs/superpowers/plans/2026-03-21-knowledge-folder-plan.md @@ -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('/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 + + + + + + +``` + +- [ ] **Step 2: 提交** + +--- + +## Task 11: 前端 - 改造 KnowledgeView + +**Files:** +- Modify: `frontend/src/views/KnowledgeView.vue` + +- [ ] **Step 1: 添加文件夹侧边栏和交互逻辑** + +主要改动: +- 导入 FolderTree 组件 +- 添加文件夹状态和加载逻辑 +- 修改上传逻辑(需先选择文件夹) +- 添加新建/重命名/删除文件夹的弹窗 + +```vue + + + + +``` + +- [ ] **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) diff --git a/docs/superpowers/specs/2026-03-20-agent-dashboard-design.md b/docs/superpowers/specs/2026-03-20-agent-dashboard-design.md new file mode 100644 index 0000000..b3bbabc --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-agent-dashboard-design.md @@ -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 +``` diff --git a/docs/superpowers/specs/2026-03-20-chat-enhancement-design.md b/docs/superpowers/specs/2026-03-20-chat-enhancement-design.md new file mode 100644 index 0000000..fac2768 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-chat-enhancement-design.md @@ -0,0 +1,192 @@ +# 沟通系统增强设计 + +## 1. 概述与目标 + +在沟通系统(ChatView)中增加两个功能: +1. **文件上传** - 用户可在对话中上传文件,AI 自动理解内容并回复 +2. **表情包选择器** - 在发送按钮旁添加 emoji 选择面板 + +## 2. 技术方案 + +### 2.1 文件上传 + +**前端实现:** +- 在 `ChatView.vue` 输入区域添加附件按钮(Paperclip 图标) +- 使用 `` 触发文件选择 +- 支持类型:图片(jpg/png/gif/webp)、文档(pdf/doc/docx/xls/xlsx/ppt/pptx/txt) +- 文件大小限制:10MB +- 上传时显示进度状态 + +**消息气泡展示:** +- 文件上传成功后,在对话中显示文件消息气泡 +- 气泡内容:文件图标 + 文件名 + 文件大小 +- 点击可下载/预览 + +**后端实现:** +- 复用现有 `/api/documents/upload` 接口上传文件 +- 创建 KGNode(entity_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 + +``` + +### 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 +``` diff --git a/docs/superpowers/specs/2026-03-20-daily-todo-design.md b/docs/superpowers/specs/2026-03-20-daily-todo-design.md new file mode 100644 index 0000000..28a3d63 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-daily-todo-design.md @@ -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:00,APScheduler 清理 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:00(APScheduler 定时任务),也可手动触发 +- 数据来源: + 1. **看板任务**:前一天创建的、状态 ≠ done 的任务,取前 20 条(按 created_at 倒序) + 2. **对话记录**:前一天创建的对话,取其消息内容前 2000 字发给 LLM +- AI 处理流程: + 1. 查询上述数据,拼装为分析文本 + 2. 发送给 LLM,Prompt 要求输出 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) diff --git a/docs/superpowers/specs/2026-03-20-jarvis-personal-agent-design.md b/docs/superpowers/specs/2026-03-20-jarvis-personal-agent-design.md new file mode 100644 index 0000000..b5bd080 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-jarvis-personal-agent-design.md @@ -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 关系抽取(实体之间的关系) +└── 存入 SQLite(nodes + 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 助理系统的初始设计规格,将根据开发进展持续更新。* diff --git a/docs/superpowers/specs/2026-03-20-langsmith-integration-design.md b/docs/superpowers/specs/2026-03-20-langsmith-integration-design.md new file mode 100644 index 0000000..a0af719 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-langsmith-integration-design.md @@ -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 消耗统计 diff --git a/docs/superpowers/specs/2026-03-20-settings-register-design.md b/docs/superpowers/specs/2026-03-20-settings-register-design.md new file mode 100644 index 0000000..3a91ea4 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-settings-register-design.md @@ -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. 注册表单验证正常 - 密码强度、格式校验有效 diff --git a/docs/superpowers/specs/2026-03-20-stats-dashboard-design.md b/docs/superpowers/specs/2026-03-20-stats-dashboard-design.md new file mode 100644 index 0000000..4d7ae34 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-stats-dashboard-design.md @@ -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,等宽加粗 +- 趋势图:高度 24px,7个数据点 +- 标签:9px,letter-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. 数据刷新 + +- 页面进入时加载所有数据 +- 支持手动刷新按钮(每个模块独立刷新) +- 数字变化时无动画(避免干扰) diff --git a/docs/superpowers/specs/2026-03-21-forum-redesign-design.md b/docs/superpowers/specs/2026-03-21-forum-redesign-design.md new file mode 100644 index 0000000..0263adc --- /dev/null +++ b/docs/superpowers/specs/2026-03-21-forum-redesign-design.md @@ -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 + 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, + fetchLearningHistory(params: { page: number, limit: number }): Promise<{ records: LearningRecord[], total: number }>, + + // suggestions + fetchSuggestions(): Promise, + getSuggestion(id: string): Promise, + markSuggestionRead(id: string): Promise, + dismissSuggestion(id: string): Promise, + + // interactive + fetchInteractiveTopics(): Promise<{ user_initiated: InteractiveTopic[], ai_proactive: InteractiveTopic[] }>, + initiateLearning(topic: string): Promise, + getTopicDetail(id: string): Promise, +} +``` + +## 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生成摘要 → 前端展示 +``` diff --git a/docs/superpowers/specs/2026-03-21-knowledge-folder-design.md b/docs/superpowers/specs/2026-03-21-knowledge-folder-design.md new file mode 100644 index 0000000..3e0fa46 --- /dev/null +++ b/docs/superpowers/specs/2026-03-21-knowledge-folder-design.md @@ -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": "", + "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` 做路径前缀匹配 +- 前端递归组件注意防止无限循环