Add project documentation and specs
This commit is contained in:
347
docs/superpowers/implementation/phase-1-plan.md
Normal file
347
docs/superpowers/implementation/phase-1-plan.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# Jarvis 个人 AI 助理 — Phase 1 实现计划
|
||||
|
||||
> 生成日期:2026-03-20
|
||||
> 目标:完成 Jarvis 核心功能的 MVP 版本
|
||||
|
||||
---
|
||||
|
||||
## 技术栈确认
|
||||
|
||||
| 组件 | 技术选型 |
|
||||
|------|---------|
|
||||
| **后端框架** | FastAPI (Python 3.12+) |
|
||||
| **Agent 框架** | LangGraph(多 Agent 编排、工具调用、状态机) |
|
||||
| **LLM 适配器** | LangChain Claude / OpenAI / Ollama(可切换) |
|
||||
| **知识库框架** | 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`
|
||||
|
||||
是否现在开始?
|
||||
@@ -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
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [emoji: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const categories = [
|
||||
{ key: 'smile', name: '😀', label: '笑脸' },
|
||||
{ key: 'gesture', name: '👍', label: '手势' },
|
||||
{ key: 'object', name: '📦', label: '物品' },
|
||||
{ key: 'symbol', name: '💬', label: '符号' },
|
||||
]
|
||||
|
||||
const emojiData: Record<string, string[]> = {
|
||||
smile: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌'],
|
||||
gesture: ['👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🖖', '🤙', '💪', '🙏', '👏'],
|
||||
object: ['📄', '📁', '🖼️', '📊', '📝', '💾', '📧', '🔗', '📌', '🔍', '💡', '⚡'],
|
||||
symbol: ['✅', '❌', '⚠️', '🔥', '💯', '🎯', '⭐', '✨', '💬', '🗨️', '❤️', '🧡'],
|
||||
}
|
||||
|
||||
const activeCategory = ref('smile')
|
||||
|
||||
function selectEmoji(emoji: string) {
|
||||
emit('select', emoji)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="emoji-picker">
|
||||
<div class="emoji-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
:class="{ active: activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
:title="cat.label"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="emoji in emojiData[activeCategory]"
|
||||
:key="emoji"
|
||||
class="emoji-btn"
|
||||
@click="selectEmoji(emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.emoji-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.emoji-tabs button {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-tabs button:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.emoji-tabs button.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-btn:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建 FileMessage 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/chat/FileMessage.vue`
|
||||
|
||||
- [ ] **Step 1: 创建 FileMessage.vue**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FileText, Image, File } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
filename: string
|
||||
fileType: string
|
||||
fileSize: number
|
||||
}>()
|
||||
|
||||
const icon = computed(() => {
|
||||
if (props.fileType.startsWith('image/')) return Image
|
||||
if (props.fileType.includes('pdf') || props.fileType.includes('document')) return FileText
|
||||
return File
|
||||
})
|
||||
|
||||
const fileSizeDisplay = computed(() => {
|
||||
const size = props.fileSize
|
||||
if (size < 1024) return size + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
|
||||
return (size / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
const ext = computed(() => {
|
||||
const parts = props.filename.split('.')
|
||||
return parts.length > 1 ? parts.pop()?.toUpperCase() : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-message">
|
||||
<div class="file-icon">
|
||||
<component :is="icon" :size="24" />
|
||||
<span v-if="ext" class="file-ext">{{ ext }}</span>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ filename }}</span>
|
||||
<span class="file-size">{{ fileSizeDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
position: relative;
|
||||
color: var(--accent-cyan);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-ext {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -4px;
|
||||
font-size: 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--bg-void);
|
||||
background: var(--accent-cyan);
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 修改前端 API - conversation.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/conversation.ts`
|
||||
|
||||
- [ ] **Step 1: 修改 conversation.ts**
|
||||
|
||||
```typescript
|
||||
// 在 chat 方法中添加 file_ids 参数
|
||||
chat(message: string, conversationId?: string, fileIds: string[] = []) {
|
||||
return api.post('/api/conversations/chat', {
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
file_ids: fileIds,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 修改前端 API - document.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/document.ts`
|
||||
|
||||
- [ ] **Step 1: 添加 getContent 方法**
|
||||
|
||||
```typescript
|
||||
// 新增方法
|
||||
getContent(id: string) {
|
||||
return api.get<string>(`/api/documents/${id}/content`)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 修改 ChatView.vue - 添加按钮和状态
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/ChatView.vue`
|
||||
|
||||
- [ ] **Step 1: 在 script setup 中添加以下内容**
|
||||
|
||||
在 import 后添加:
|
||||
|
||||
```typescript
|
||||
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
|
||||
import FileMessage from '@/components/chat/FileMessage.vue'
|
||||
import { Paperclip, Smile, Download } from 'lucide-vue-next'
|
||||
|
||||
// 新增状态
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const showEmojiPicker = ref(false)
|
||||
const selectedFiles = ref<{ id: string; name: string; type: string; size: number }[]>([])
|
||||
const uploadingFiles = ref<{ name: string; progress: number }[]>([])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加文件上传方法**
|
||||
|
||||
```typescript
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files?.length) return
|
||||
|
||||
for (const file of input.files) {
|
||||
// 校验大小
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert(`文件 ${file.name} 超过10MB限制`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 显示上传中状态
|
||||
uploadingFiles.value.push({ name: file.name, progress: 0 })
|
||||
|
||||
try {
|
||||
const response = await documentApi.upload(file)
|
||||
selectedFiles.value.push({
|
||||
id: response.data.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('上传失败:', e)
|
||||
alert(`文件 ${file.name} 上传失败`)
|
||||
} finally {
|
||||
uploadingFiles.value = uploadingFiles.value.filter(f => f.name !== file.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空 input
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function insertEmoji(emoji: string) {
|
||||
inputMessage.value += emoji
|
||||
showEmojiPicker.value = false
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 sendMessage 中处理文件上传**
|
||||
|
||||
修改 sendMessage 函数,在发送消息时附带 file_ids:
|
||||
|
||||
```typescript
|
||||
async function sendMessage() {
|
||||
if (!inputMessage.value.trim() || isSending.value) return
|
||||
|
||||
// 如果有文件,先上传
|
||||
if (selectedFiles.value.length > 0) {
|
||||
// file_ids 已经在 selectedFiles 中
|
||||
}
|
||||
|
||||
isSending.value = true
|
||||
isTyping.value = true
|
||||
const text = inputMessage.value.trim()
|
||||
const fileIds = selectedFiles.value.map(f => f.id)
|
||||
|
||||
// 添加用户消息(带文件)
|
||||
store.addMessage({
|
||||
id: `temp-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
attachments: selectedFiles.value,
|
||||
})
|
||||
|
||||
inputMessage.value = ''
|
||||
selectedFiles.value = [] // 清空已选文件
|
||||
|
||||
// ... 后续发送逻辑,传入 fileIds
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 template 中添加附件和 Emoji 按钮**
|
||||
|
||||
在输入框的按钮区域添加:
|
||||
|
||||
```vue
|
||||
<!-- 文件选择 input(隐藏) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 附件按钮 -->
|
||||
<button class="attach-btn" @click="openFilePicker" title="上传文件">
|
||||
<Paperclip :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- Emoji 按钮 -->
|
||||
<div class="emoji-wrapper">
|
||||
<button
|
||||
class="emoji-btn"
|
||||
:class="{ active: showEmojiPicker }"
|
||||
@click="showEmojiPicker = !showEmojiPicker"
|
||||
title="表情包"
|
||||
>
|
||||
<Smile :size="15" />
|
||||
</button>
|
||||
<EmojiPicker
|
||||
:visible="showEmojiPicker"
|
||||
@select="insertEmoji"
|
||||
@close="showEmojiPicker = false"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 添加样式**
|
||||
|
||||
在 style 部分添加:
|
||||
|
||||
```css
|
||||
/* 文件消息样式 */
|
||||
.file-msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.file-msg-row .msg-avatar {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 附件按钮 */
|
||||
.attach-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.attach-btn:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Emoji 按钮 */
|
||||
.emoji-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-btn:hover,
|
||||
.emoji-btn.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 修改后端 - ChatRequest 支持 file_ids
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/conversation.py`
|
||||
|
||||
- [ ] **Step 1: 修改 ChatRequest**
|
||||
|
||||
```python
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
conversation_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
file_ids: list[str] = [] # 新增
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 修改后端 - Message 新增 attachments 字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/conversation.py`
|
||||
|
||||
- [ ] **Step 1: 修改 Message 模型**
|
||||
|
||||
```python
|
||||
class Message(BaseModel):
|
||||
__tablename__ = "messages"
|
||||
|
||||
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False, index=True)
|
||||
role = Column(String(20), nullable=False) # user, assistant, system
|
||||
content = Column(Text, nullable=False)
|
||||
model = Column(String(100), nullable=True)
|
||||
tokens_used = Column(Integer, nullable=True)
|
||||
attachments = Column(JSON, nullable=True) # 新增: [{file_id, filename, file_type, file_size}]
|
||||
|
||||
conversation = relationship("Conversation", back_populates="messages")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 修改后端 - 新增 document content 接口
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/document.py`
|
||||
|
||||
- [ ] **Step 1: 添加 content 接口**
|
||||
|
||||
```python
|
||||
@router.get("/{document_id}/content")
|
||||
async def get_document_content(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取文档的文本内容(用于AI理解)"""
|
||||
from app.services.document_service import DocumentService
|
||||
|
||||
doc_svc = DocumentService(db)
|
||||
content = await doc_svc.get_document_content(current_user.id, document_id)
|
||||
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="文档不存在或无内容")
|
||||
|
||||
return {"content": content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 修改后端 - DocumentService 新增 get_document_content
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/document_service.py`
|
||||
|
||||
- [ ] **Step 1: 添加 get_document_content 方法**
|
||||
|
||||
```python
|
||||
async def get_document_content(self, user_id: str, document_id: str) -> str | None:
|
||||
"""获取文档的文本内容"""
|
||||
import os
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.user_id == user_id,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
return None
|
||||
|
||||
file_path = doc.file_path
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
# 根据文件类型读取内容
|
||||
ext = doc.filename.split('.')[-1].lower()
|
||||
|
||||
try:
|
||||
if ext == 'txt':
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
elif ext == 'md':
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
elif ext == 'pdf':
|
||||
# 简单文本提取(生产环境应使用专业库)
|
||||
# 这里可以先用 pdfplumber 或 PyPDF2
|
||||
return f"[PDF文档] {doc.filename}"
|
||||
else:
|
||||
return f"[文档] {doc.filename}"
|
||||
except Exception:
|
||||
return f"[文档] {doc.filename}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: 修改后端 - AgentService 支持文件上下文
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/agent_service.py`
|
||||
|
||||
- [ ] **Step 1: 修改 chat_simple 方法支持 file_ids**
|
||||
|
||||
在 chat_simple 方法中:
|
||||
|
||||
```python
|
||||
async def chat_simple(
|
||||
self,
|
||||
user_id: str,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
file_ids: list[str] = None,
|
||||
) -> tuple[str, str, str]:
|
||||
# ... 现有逻辑 ...
|
||||
|
||||
# 如果有文件,读取内容作为上下文
|
||||
file_context = ""
|
||||
if file_ids:
|
||||
from app.services.document_service import DocumentService
|
||||
doc_svc = DocumentService(self.db)
|
||||
|
||||
for file_id in file_ids:
|
||||
content = await doc_svc.get_document_content(user_id, file_id)
|
||||
if content:
|
||||
file_context += f"\n\n[用户上传文件内容]\n{content}\n[/文件内容]"
|
||||
|
||||
# 将文件上下文添加到消息
|
||||
full_message = f"{message}\n{file_context}" if file_context else message
|
||||
|
||||
# 调用 LLM
|
||||
response = await self.llm.chat(full_message, ...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 验证和测试
|
||||
|
||||
- [ ] **Step 1: 前端 TypeScript 检查**
|
||||
|
||||
```bash
|
||||
cd frontend && npx vue-tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 后端语法检查**
|
||||
|
||||
```bash
|
||||
cd backend && python -m py_compile app/routers/conversation.py app/services/agent_service.py app/services/document_service.py
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 启动服务测试**
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend && python -m uvicorn app.main:app --reload
|
||||
|
||||
# 前端
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行选项
|
||||
|
||||
**1. Subagent-Driven (推荐)** - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代
|
||||
|
||||
**2. Inline Execution** - 在当前会话中按批次执行任务
|
||||
|
||||
选择哪种方式?
|
||||
44
docs/superpowers/plans/2026-03-20-daily-todo-migration.md
Normal file
44
docs/superpowers/plans/2026-03-20-daily-todo-migration.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Daily Todo 数据库迁移
|
||||
|
||||
## 自动迁移
|
||||
|
||||
SQLAlchemy 会在应用启动时通过 `init_db()` 自动创建所有表,包括 `daily_todos` 表。
|
||||
|
||||
## 手动迁移(如需)
|
||||
|
||||
如果需要手动创建表,执行以下 SQL:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS daily_todos (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
is_completed BOOLEAN NOT NULL DEFAULT 0,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
source_detail VARCHAR(500),
|
||||
source_ref_id VARCHAR(36),
|
||||
todo_date VARCHAR(10) NOT NULL,
|
||||
completed_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_date ON daily_todos(user_id, todo_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_id ON daily_todos(user_id);
|
||||
```
|
||||
|
||||
## 表说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(36) | 主键,UUID |
|
||||
| user_id | VARCHAR(36) | 所属用户,索引 |
|
||||
| title | VARCHAR(500) | 待办标题 |
|
||||
| is_completed | BOOLEAN | 是否完成,默认 false |
|
||||
| source | VARCHAR(20) | 来源:ai_kanban / ai_chat / manual |
|
||||
| source_detail | VARCHAR(500) | 来源说明文本 |
|
||||
| source_ref_id | VARCHAR(36) | 来源原始ID(看板TaskID或对话ID) |
|
||||
| todo_date | VARCHAR(10) | 所属日期 YYYY-MM-DD |
|
||||
| completed_at | TIMESTAMP | 完成时间 |
|
||||
| created_at | TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 更新时间 |
|
||||
1184
docs/superpowers/plans/2026-03-20-daily-todo-plan.md
Normal file
1184
docs/superpowers/plans/2026-03-20-daily-todo-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
215
docs/superpowers/plans/2026-03-20-langsmith-integration.md
Normal file
215
docs/superpowers/plans/2026-03-20-langsmith-integration.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# LangSmith 集成实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 Jarvis 后端集成 LangSmith 追踪,实现调用追踪、对话历史管理和评估支持。
|
||||
|
||||
**Architecture:** LangGraph 的 `compile()` 方法接受全局 Callbacks 参数,会自动将 Callback 传播到所有节点的 LLM 调用。只需在 Graph 编译时注入 `LangChainTracer`,即可覆盖 Master/Planner/Executor/Librarian/Analyst 所有 5 个节点和工具调用。
|
||||
|
||||
**Tech Stack:** langsmith, langchain-core, langgraph
|
||||
|
||||
---
|
||||
|
||||
## 文件变更总览
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `backend/pyproject.toml` | 添加 langsmith 依赖 |
|
||||
| `backend/.env.example` | 新增 LangSmith 环境变量 |
|
||||
| `backend/app/config.py` | 新增 3 个配置字段 |
|
||||
| `backend/app/config_tracing.py` | 新建,callback 工厂函数 |
|
||||
| `backend/app/agents/graph.py` | 修改 `create_agent_graph()` 支持 callbacks,合并全局 callbacks |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 添加 langsmith 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/pyproject.toml`
|
||||
|
||||
- [ ] **Step 1: 添加 langsmith 依赖**
|
||||
|
||||
在 `dependencies` 数组中 `"langchain-ollama>=0.4.0",` 后添加:
|
||||
|
||||
```toml
|
||||
"langchain-ollama>=0.4.0",
|
||||
|
||||
# 可观测性
|
||||
"langsmith>=0.1.0",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 安装依赖**
|
||||
|
||||
Run: `cd backend && uv sync`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 添加环境变量模板
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/.env.example`
|
||||
|
||||
- [ ] **Step 1: 在文件末尾添加 LangSmith 配置节**
|
||||
|
||||
在 `# === 定时任务 ===` 节之前添加:
|
||||
|
||||
```env
|
||||
# === LangSmith 可观测性 ===
|
||||
# 启用 LangSmith 追踪(可选)
|
||||
LANGSMITH_TRACING=false
|
||||
LANGSMITH_API_KEY=your-langsmith-api-key
|
||||
LANGSMITH_PROJECT=jarvis-agent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Config 层添加 LangSmith 配置
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/config.py`
|
||||
- Create: `backend/app/config_tracing.py` (callback 工厂函数)
|
||||
|
||||
- [ ] **Step 1: 在 Settings 类中添加 3 个配置字段**
|
||||
|
||||
在 `# === NAS 部署 ===` 节之前添加:
|
||||
|
||||
```python
|
||||
# === LangSmith 可观测性 ===
|
||||
LANGSMITH_TRACING: bool = False
|
||||
LANGSMITH_API_KEY: str = ""
|
||||
LANGSMITH_PROJECT: str = "jarvis-agent"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 callback 工厂函数**
|
||||
|
||||
创建新文件 `backend/app/config_tracing.py`:
|
||||
|
||||
```python
|
||||
"""
|
||||
LangSmith Tracing 配置
|
||||
提供 Callback 工厂函数,用于 LangGraph 追踪
|
||||
"""
|
||||
|
||||
from langchain_core.callbacks import LangChainTracer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def get_langsmith_callbacks() -> list:
|
||||
"""
|
||||
根据配置返回 LangSmith Callback 列表
|
||||
未启用时返回空列表
|
||||
"""
|
||||
if not settings.LANGSMITH_TRACING:
|
||||
return []
|
||||
|
||||
if not settings.LANGSMITH_API_KEY:
|
||||
return []
|
||||
|
||||
return [
|
||||
LangChainTracer(
|
||||
project_name=settings.LANGSMITH_PROJECT,
|
||||
)
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 修改 Graph 接受 Callbacks
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/agents/graph.py`
|
||||
|
||||
- [ ] **Step 1: 修改 create_agent_graph() 签名**
|
||||
|
||||
将函数签名从:
|
||||
|
||||
```python
|
||||
def create_agent_graph():
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
def create_agent_graph(callbacks: list | None = None):
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 将 callbacks 传给 compile()**
|
||||
|
||||
将:
|
||||
|
||||
```python
|
||||
return graph.compile()
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
return graph.compile(callbacks=callbacks)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 get_agent_graph() 注入默认 callbacks**
|
||||
|
||||
将:
|
||||
|
||||
```python
|
||||
def get_agent_graph():
|
||||
global _agent_graph
|
||||
if _agent_graph is None:
|
||||
_agent_graph = create_agent_graph()
|
||||
return _agent_graph
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
from app.config_tracing import get_langsmith_callbacks
|
||||
|
||||
|
||||
def get_agent_graph(callbacks: list | None = None):
|
||||
"""
|
||||
获取编译好的 Agent 图(单例缓存)。
|
||||
|
||||
Callbacks 在首次编译时固定注入,后续调用忽略 callbacks 参数。
|
||||
如需变更 Callbacks(如修改 LANGCHAIN_PROJECT),需重启服务。
|
||||
|
||||
Args:
|
||||
callbacks: 可选的额外 Callbacks,会与全局 LangSmith Callbacks 合并
|
||||
"""
|
||||
global _agent_graph
|
||||
if _agent_graph is None:
|
||||
langsmith_callbacks = get_langsmith_callbacks()
|
||||
all_callbacks = (callbacks or []) + langsmith_callbacks
|
||||
_agent_graph = create_agent_graph(callbacks=all_callbacks or None)
|
||||
return _agent_graph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 验证集成
|
||||
|
||||
- [ ] **Step 1: 确认依赖安装**
|
||||
|
||||
Run: `cd backend && uv sync`
|
||||
|
||||
- [ ] **Step 2: 启动服务验证无报错**
|
||||
|
||||
Run: `cd backend && uv run uvicorn app.main:app --reload --port 8000`
|
||||
|
||||
- [ ] **Step 3: 配置 .env 并测试**
|
||||
|
||||
在 `.env` 中添加:
|
||||
|
||||
```env
|
||||
LANGSMITH_TRACING=true
|
||||
LANGSMITH_API_KEY=your-api-key
|
||||
LANGSMITH_PROJECT=jarvis-agent
|
||||
```
|
||||
|
||||
发起一次 Agent 对话,访问 https://smith.langchain.com 确认 trace 出现。
|
||||
|
||||
预期在 Dashboard 中看到:
|
||||
- 5 个节点(master/planner/executor/librarian/analyst)的执行记录
|
||||
- 每个节点的 LLM 输入/输出
|
||||
- 工具调用记录
|
||||
- Token 消耗统计
|
||||
903
docs/superpowers/plans/2026-03-20-settings-register-plan.md
Normal file
903
docs/superpowers/plans/2026-03-20-settings-register-plan.md
Normal file
@@ -0,0 +1,903 @@
|
||||
# 注册界面 + 设置界面 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现用户注册界面和设置界面,支持多用户和用户级 LLM 配置
|
||||
|
||||
**Architecture:**
|
||||
- 后端:扩展 User 模型,新建 settings router/service,前端认证依赖现有 auth 机制
|
||||
- 前端:LoginView 添加注册 Tab,新建 SettingsView 页面,复用现有 sci-fi 风格
|
||||
- 数据:User 表增加 JSON 字段存储 llm_config 和 scheduler_config
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy + Vue 3 + axios + Pinia
|
||||
|
||||
---
|
||||
|
||||
## 文件总览
|
||||
|
||||
```
|
||||
backend/
|
||||
app/
|
||||
models/
|
||||
user.py # 修改:添加 llm_config, scheduler_config 字段
|
||||
schemas/
|
||||
settings.py # 新建:Settings Pydantic schemas
|
||||
routers/
|
||||
settings.py # 新建:settings API router
|
||||
services/
|
||||
settings_service.py # 新建:设置逻辑服务
|
||||
|
||||
frontend/
|
||||
src/
|
||||
api/
|
||||
settings.ts # 新建:settings API 客户端
|
||||
views/
|
||||
LoginView.vue # 修改:添加注册 Tab
|
||||
SettingsView.vue # 新建:设置页面
|
||||
router/
|
||||
index.ts # 修改:添加 /settings 路由
|
||||
components/
|
||||
SidebarNav.vue # 修改:添加设置菜单
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 后端 - User 模型扩展
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/user.py`
|
||||
|
||||
- [ ] **Step 1: 添加 JSON 字段到 User 模型**
|
||||
|
||||
读取现有 User 模型,添加 llm_config 和 scheduler_config 字段:
|
||||
|
||||
```python
|
||||
# 在 User 模型类中添加
|
||||
from sqlalchemy import JSON
|
||||
|
||||
llm_config = Column(JSON, nullable=True) # 用户 LLM 配置
|
||||
scheduler_config = Column(JSON, nullable=True) # 定时任务配置
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 设置默认值**
|
||||
|
||||
确保新用户创建时有默认配置(在 User 模型或 service 层处理)
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/user.py
|
||||
git commit -m "feat(settings): add llm_config and scheduler_config fields to User model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 后端 - Settings Schema 定义
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/settings.py`
|
||||
|
||||
- [ ] **Step 1: 创建 settings schemas**
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
# LLM Provider 类型
|
||||
LLMProviderType = Literal["openai", "claude", "ollama", "deepseek", "custom"]
|
||||
LLMType = Literal["chat", "vlm", "embedding", "rerank"]
|
||||
|
||||
# 单个模型配置
|
||||
class LLMModelConfig(BaseModel):
|
||||
provider: LLMProviderType = "openai"
|
||||
model: str = ""
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
|
||||
# LLM 配置输入
|
||||
class LLMConfigIn(BaseModel):
|
||||
chat: Optional[LLMModelConfig] = None
|
||||
vlm: Optional[LLMModelConfig] = None
|
||||
embedding: Optional[LLMModelConfig] = None
|
||||
rerank: Optional[LLMModelConfig] = None
|
||||
|
||||
# 定时任务配置
|
||||
class SchedulerConfigIn(BaseModel):
|
||||
daily_plan_time: Optional[str] = "08:00"
|
||||
forum_scan_interval_minutes: Optional[int] = 30
|
||||
todo_ai_generate_time: Optional[str] = "08:00"
|
||||
enabled: Optional[bool] = True
|
||||
|
||||
# 用户资料更新
|
||||
class ProfileUpdateIn(BaseModel):
|
||||
full_name: Optional[str] = Field(None, min_length=2, max_length=50)
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
current_password: Optional[str] = None # 修改密码时需要验证
|
||||
|
||||
# 完整设置输出
|
||||
class SettingsOut(BaseModel):
|
||||
profile: "UserOut" # 引用 auth.py 中的 UserOut
|
||||
llm_config: Optional[dict] = None
|
||||
scheduler_config: Optional[dict] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
# 测试 LLM 连接请求
|
||||
class LLMTestIn(BaseModel):
|
||||
type: LLMType
|
||||
provider: LLMProviderType
|
||||
model: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/settings.py
|
||||
git commit -m "feat(settings): add Pydantic schemas for settings API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 后端 - Settings Service
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/settings_service.py`
|
||||
|
||||
- [ ] **Step 1: 创建设置服务**
|
||||
|
||||
主要功能:
|
||||
1. `get_user_settings(user_id)` - 获取用户完整设置
|
||||
2. `update_user_profile(user_id, data)` - 更新用户资料
|
||||
3. `update_llm_config(user_id, config)` - 更新 LLM 配置
|
||||
4. `update_scheduler_config(user_id, config)` - 更新定时任务配置
|
||||
5. `test_llm_connection(data)` - 测试 LLM 连接
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import verify_password, get_password_hash
|
||||
from app.services.llm_service import get_llm
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_user_settings(user_id: str, db: AsyncSession) -> dict:
|
||||
"""获取用户完整设置"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
return {
|
||||
"profile": user,
|
||||
"llm_config": user.llm_config or {},
|
||||
"scheduler_config": user.scheduler_config or {}
|
||||
}
|
||||
|
||||
async def update_user_profile(
|
||||
user_id: str,
|
||||
db: AsyncSession,
|
||||
full_name: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
current_password: Optional[str] = None
|
||||
) -> User:
|
||||
"""更新用户资料"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
if password:
|
||||
if not current_password or not verify_password(current_password, user.hashed_password):
|
||||
raise ValueError("当前密码错误")
|
||||
user.hashed_password = get_password_hash(password)
|
||||
|
||||
if full_name:
|
||||
user.full_name = full_name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
async def update_llm_config(user_id: str, config: dict, db: AsyncSession) -> dict:
|
||||
"""更新 LLM 配置"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
current = user.llm_config or {}
|
||||
# 合并配置
|
||||
for key, value in config.items():
|
||||
if value is not None:
|
||||
current[key] = value
|
||||
user.llm_config = current
|
||||
await db.commit()
|
||||
return current
|
||||
|
||||
async def update_scheduler_config(user_id: str, config: dict, db: AsyncSession) -> dict:
|
||||
"""更新定时任务配置"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
current = user.scheduler_config or {}
|
||||
for key, value in config.items():
|
||||
if value is not None:
|
||||
current[key] = value
|
||||
user.scheduler_config = current
|
||||
await db.commit()
|
||||
return current
|
||||
|
||||
async def test_llm_connection(
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: str,
|
||||
api_key: str
|
||||
) -> dict:
|
||||
"""测试 LLM 连接"""
|
||||
try:
|
||||
# 根据不同 provider 创建临时 LLM 实例并测试
|
||||
if provider == "openai":
|
||||
from langchain_openai import ChatOpenAI
|
||||
llm = ChatOpenAI(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url or None,
|
||||
timeout=30
|
||||
)
|
||||
elif provider == "claude":
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
llm = ChatAnthropic(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
timeout=30
|
||||
)
|
||||
elif provider == "ollama":
|
||||
from langchain_ollama import ChatOllama
|
||||
llm = ChatOllama(
|
||||
base_url=base_url or "http://localhost:11434",
|
||||
model=model,
|
||||
timeout=30
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
from langchain_openai import ChatOpenAI
|
||||
llm = ChatOpenAI(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url or "https://api.deepseek.com/v1",
|
||||
timeout=30
|
||||
)
|
||||
else:
|
||||
return {"success": False, "error": f"不支持的 provider: {provider}"}
|
||||
|
||||
# 简单测试调用
|
||||
response = await llm.ainvoke([HumanMessage(content="Hi")])
|
||||
return {"success": True, "message": f"连接成功,模型响应: {response.content[:50]}..."}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/settings_service.py
|
||||
git commit -m "feat(settings): add settings service with LLM config management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 后端 - Settings Router
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/settings.py`
|
||||
|
||||
- [ ] **Step 1: 创建 settings router**
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.settings import (
|
||||
SettingsOut, ProfileUpdateIn, LLMConfigIn, SchedulerConfigIn, LLMTestIn
|
||||
)
|
||||
from app.services.settings_service import (
|
||||
get_user_settings, update_user_profile, update_llm_config,
|
||||
update_scheduler_config, test_llm_connection
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["设置"])
|
||||
|
||||
@router.get("", response_model=SettingsOut)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
settings = await get_user_settings(current_user.id, db)
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return settings
|
||||
|
||||
@router.put("/profile")
|
||||
async def update_profile(
|
||||
data: ProfileUpdateIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
user = await update_user_profile(
|
||||
current_user.id, db,
|
||||
full_name=data.full_name,
|
||||
password=data.password,
|
||||
current_password=data.current_password
|
||||
)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.put("/llm")
|
||||
async def update_llm(
|
||||
data: LLMConfigIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
config = await update_llm_config(current_user.id, data.model_dump(exclude_none=True), db)
|
||||
return {"llm_config": config}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/llm/test")
|
||||
async def test_llm(
|
||||
data: LLMTestIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await test_llm_connection(
|
||||
provider=data.provider,
|
||||
model=data.model,
|
||||
base_url=data.base_url,
|
||||
api_key=data.api_key
|
||||
)
|
||||
return result
|
||||
|
||||
@router.put("/scheduler")
|
||||
async def update_scheduler(
|
||||
data: SchedulerConfigIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
config = await update_scheduler_config(
|
||||
current_user.id,
|
||||
data.model_dump(exclude_none=True),
|
||||
db
|
||||
)
|
||||
return {"scheduler_config": config}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册 router 到 main.py 和 routers/__init__.py**
|
||||
|
||||
在 `backend/app/routers/__init__.py` 添加:
|
||||
```python
|
||||
from app.routers.settings import router as settings_router
|
||||
```
|
||||
|
||||
在 `backend/app/main.py` 添加:
|
||||
```python
|
||||
from app.routers.settings import router as settings_router
|
||||
# ...
|
||||
app.include_router(settings_router)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/routers/settings.py backend/app/routers/__init__.py backend/app/main.py
|
||||
git commit -m "feat(settings): add settings router with profile, LLM and scheduler endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 前端 - Settings API 客户端
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/settings.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 settings API 客户端**
|
||||
|
||||
```typescript
|
||||
import api from './index'
|
||||
|
||||
export type LLMProvider = 'openai' | 'claude' | 'ollama' | 'deepseek' | 'custom'
|
||||
export type LLMType = 'chat' | 'vlm' | 'embedding' | 'rerank'
|
||||
|
||||
export interface LLMModelConfig {
|
||||
provider: LLMProvider
|
||||
model: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
}
|
||||
|
||||
export interface LLMConfig {
|
||||
chat?: LLMModelConfig
|
||||
vlm?: LLMModelConfig
|
||||
embedding?: LLMModelConfig
|
||||
rerank?: LLMModelConfig
|
||||
}
|
||||
|
||||
export interface SchedulerConfig {
|
||||
daily_plan_time?: string
|
||||
forum_scan_interval_minutes?: number
|
||||
todo_ai_generate_time?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface ProfileUpdate {
|
||||
full_name?: string
|
||||
password?: string
|
||||
current_password?: string
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
profile: {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
created_at: string
|
||||
}
|
||||
llm_config: LLMConfig
|
||||
scheduler_config: SchedulerConfig
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
// 获取设置
|
||||
get() {
|
||||
return api.get<SettingsResponse>('/api/settings')
|
||||
},
|
||||
|
||||
// 更新资料
|
||||
updateProfile(data: ProfileUpdate) {
|
||||
return api.put('/api/settings/profile', data)
|
||||
},
|
||||
|
||||
// 更新 LLM 配置
|
||||
updateLLM(config: Partial<LLMConfig>) {
|
||||
return api.put('/api/settings/llm', config)
|
||||
},
|
||||
|
||||
// 测试 LLM 连接
|
||||
testLLM(data: { type: LLMType } & LLMModelConfig) {
|
||||
return api.post('/api/settings/llm/test', data)
|
||||
},
|
||||
|
||||
// 更新定时任务配置
|
||||
updateScheduler(config: Partial<SchedulerConfig>) {
|
||||
return api.put('/api/settings/scheduler', config)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/settings.ts
|
||||
git commit -m "feat(settings): add settings API client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 前端 - LoginView 注册功能
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/LoginView.vue`
|
||||
|
||||
- [ ] **Step 1: 添加注册 Tab 和表单**
|
||||
|
||||
在 script setup 中添加:
|
||||
```typescript
|
||||
const isLogin = ref(true)
|
||||
const registerEmail = ref('')
|
||||
const registerPassword = ref('')
|
||||
const registerConfirmPassword = ref('')
|
||||
const registerName = ref('')
|
||||
const isRegistering = ref(false)
|
||||
const registerError = ref('')
|
||||
|
||||
// 密码强度计算
|
||||
function getPasswordStrength(pwd: string): { level: 'weak' | 'medium' | 'strong', text: string } {
|
||||
if (pwd.length < 8) return { level: 'weak', text: '太短' }
|
||||
let score = 0
|
||||
if (pwd.length >= 8) score++
|
||||
if (pwd.length >= 12) score++
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
|
||||
if (/\d/.test(pwd)) score++
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) score++
|
||||
if (score <= 2) return { level: 'weak', text: '弱' }
|
||||
if (score <= 3) return { level: 'medium', text: '中' }
|
||||
return { level: 'strong', text: '强' }
|
||||
}
|
||||
|
||||
const passwordStrength = computed(() => getPasswordStrength(registerPassword.value))
|
||||
|
||||
async function handleRegister() {
|
||||
if (registerPassword.value !== registerConfirmPassword.value) {
|
||||
registerError.value = '两次密码输入不一致'
|
||||
return
|
||||
}
|
||||
if (registerPassword.value.length < 8) {
|
||||
registerError.value = '密码至少需要8个字符'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
registerError.value = ''
|
||||
isRegistering.value = true
|
||||
await authApi.register({
|
||||
email: registerEmail.value,
|
||||
password: registerPassword.value,
|
||||
full_name: registerName.value
|
||||
})
|
||||
// 注册成功后自动登录
|
||||
await auth.login(registerEmail.value, registerPassword.value)
|
||||
router.push('/chat')
|
||||
} catch (e: unknown) {
|
||||
registerError.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '注册失败'
|
||||
} finally {
|
||||
isRegistering.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 template 中添加注册表单(与登录表单并列,用 v-if 切换)
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/LoginView.vue
|
||||
git commit -m "feat(auth): add registration form to LoginView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 前端 - SettingsView 页面
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/SettingsView.vue`
|
||||
|
||||
- [ ] **Step 1: 创建设置页面**
|
||||
|
||||
页面结构:
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig } from '@/api/settings'
|
||||
import { Save, RotateCcw, Eye, EyeOff, Play } from 'lucide-vue-next'
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showApiKey = ref<Record<string, boolean>>({})
|
||||
|
||||
// 用户资料
|
||||
const profile = ref({ email: '', full_name: '' })
|
||||
const newPassword = ref('')
|
||||
|
||||
// LLM 配置
|
||||
const llmConfig = ref<LLMConfig>({
|
||||
chat: { provider: 'openai', model: 'gpt-4o', base_url: '', api_key: '' },
|
||||
vlm: { provider: 'openai', model: 'gpt-4o', base_url: '', api_key: '' },
|
||||
embedding: { provider: 'openai', model: 'text-embedding-3-small', base_url: '', api_key: '' },
|
||||
rerank: { provider: 'openai', model: 'bge-reranker-v2', base_url: '', api_key: '' },
|
||||
})
|
||||
|
||||
// 定时任务配置
|
||||
const schedulerConfig = ref<SchedulerConfig>({
|
||||
daily_plan_time: '08:00',
|
||||
forum_scan_interval_minutes: 30,
|
||||
todo_ai_generate_time: '08:00',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// 加载设置
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await settingsApi.get()
|
||||
profile.value = { ...res.data.profile }
|
||||
llmConfig.value = { ...res.data.llm_config }
|
||||
schedulerConfig.value = { ...res.data.scheduler_config }
|
||||
} catch (e) {
|
||||
console.error('加载设置失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存资料
|
||||
async function saveProfile() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.updateProfile({ full_name: profile.value.full_name })
|
||||
if (newPassword.value) {
|
||||
const currentPwd = prompt('请输入当前密码以确认修改:')
|
||||
if (!currentPwd) {
|
||||
alert('密码修改已取消')
|
||||
return
|
||||
}
|
||||
await settingsApi.updateProfile({
|
||||
password: newPassword.value,
|
||||
current_password: currentPwd
|
||||
})
|
||||
newPassword.value = ''
|
||||
alert('密码修改成功')
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 LLM 配置
|
||||
async function saveLLM() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.updateLLM(llmConfig.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 LLM 连接
|
||||
async function testLLM(type: string, config: LLMModelConfig) {
|
||||
try {
|
||||
const res = await settingsApi.testLLM({ type, ...config })
|
||||
alert(res.data.success ? `成功: ${res.data.message}` : `失败: ${res.data.error}`)
|
||||
} catch (e) {
|
||||
alert('测试连接失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存定时任务配置
|
||||
async function saveScheduler() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.updateScheduler(schedulerConfig.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 默认值
|
||||
function getDefaultBaseUrl(provider: string) {
|
||||
switch (provider) {
|
||||
case 'ollama': return 'http://localhost:11434'
|
||||
case 'openai': return 'https://api.openai.com/v1'
|
||||
case 'claude': return 'https://api.anthropic.com'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-view">
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<span class="header-title">SETTINGS</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="settings-content">
|
||||
<!-- Profile Section -->
|
||||
<div class="settings-card">
|
||||
<div class="card-title">PROFILE</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input v-model="profile.email" type="email" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input v-model="profile.full_name" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input v-model="newPassword" type="password" placeholder="Leave empty to keep current" />
|
||||
</div>
|
||||
<button class="save-btn" @click="saveProfile" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Profile' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- LLM Config Section -->
|
||||
<div v-for="(config, type) in llmConfig" :key="type" class="settings-card">
|
||||
<div class="card-title">{{ type.toUpperCase() }}</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Provider</label>
|
||||
<select v-model="config.provider">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Model</label>
|
||||
<input v-model="config.model" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Base URL</label>
|
||||
<input v-model="config.base_url" type="text" :placeholder="getDefaultBaseUrl(config.provider)" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<div class="input-with-toggle">
|
||||
<input v-model="config.api_key" :type="showApiKey[type] ? 'text' : 'password'" />
|
||||
<button @click="showApiKey[type] = !showApiKey[type]" class="toggle-btn">
|
||||
<Eye v-if="!showApiKey[type]" :size="14" />
|
||||
<EyeOff v-else :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="test-btn" @click="testLLM(type, config)">
|
||||
<Play :size="12" /> Test
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scheduler Section -->
|
||||
<div class="settings-card">
|
||||
<div class="card-title">SCHEDULER</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Daily Plan Time</label>
|
||||
<input v-model="schedulerConfig.daily_plan_time" type="time" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Todo AI Generate Time</label>
|
||||
<input v-model="schedulerConfig.todo_ai_generate_time" type="time" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Forum Scan Interval (minutes)</label>
|
||||
<input v-model.number="schedulerConfig.forum_scan_interval_minutes" type="number" min="5" max="1440" />
|
||||
</div>
|
||||
<div class="form-group toggle-group">
|
||||
<label>Scheduler Enabled</label>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: schedulerConfig.enabled }"
|
||||
@click="schedulerConfig.enabled = !schedulerConfig.enabled"
|
||||
>
|
||||
<span class="toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="save-btn" @click="saveScheduler" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Scheduler' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
样式部分复用 AgentView 的 sci-fi 风格,保持一致。
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/SettingsView.vue
|
||||
git commit -m "feat(settings): add SettingsView page with profile, LLM and scheduler config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 前端 - 路由和侧边栏
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
- Modify: `frontend/src/components/SidebarNav.vue`
|
||||
|
||||
- [ ] **Step 1: 添加 /settings 路由**
|
||||
|
||||
在 children 数组中添加:
|
||||
```typescript
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加设置菜单项**
|
||||
|
||||
在 navItems 中添加:
|
||||
```typescript
|
||||
{ name: '设置', path: '/settings', icon: Settings },
|
||||
```
|
||||
|
||||
导入 Settings 图标:
|
||||
```typescript
|
||||
import { Settings } from 'lucide-vue-next'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/router/index.ts frontend/src/components/SidebarNav.vue
|
||||
git commit -m "feat(settings): add /settings route and sidebar menu"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 数据库迁移
|
||||
|
||||
- [ ] **Step 1: 创建迁移 SQL**
|
||||
|
||||
由于使用 SQLAlchemy 的 `init_db()` 会在启动时自动创建表,但现有数据库不会自动添加新字段。需要:
|
||||
|
||||
1. 直接在数据库上执行 ALTER TABLE:
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN llm_config TEXT;
|
||||
ALTER TABLE users ADD COLUMN scheduler_config TEXT;
|
||||
```
|
||||
|
||||
2. 或通过 Python 脚本:
|
||||
```python
|
||||
import asyncio
|
||||
from app.database import engine
|
||||
|
||||
async def migrate():
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN llm_config TEXT'))
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN scheduler_config TEXT'))
|
||||
print('Migration complete')
|
||||
|
||||
asyncio.run(migrate())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交迁移脚本**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-03-20-settings-migration.md
|
||||
git commit -m "feat(settings): add database migration for user settings fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
完成所有 Task 后,验证以下内容:
|
||||
|
||||
1. **注册功能** - 可以通过注册页面创建新账号
|
||||
2. **登录功能** - 新老用户都可以正常登录
|
||||
3. **设置页面** - 可以访问 /settings 页面
|
||||
4. **资料修改** - 用户名、密码可以修改
|
||||
5. **LLM 配置** - 四种模型配置可以保存
|
||||
6. **LLM 测试** - 测试连接功能正常
|
||||
7. **定时任务** - 时间间隔可以修改
|
||||
8. **配置持久化** - 重新登录后配置保留
|
||||
9. **UI 风格** - 设置页面风格与其他页面一致
|
||||
|
||||
---
|
||||
|
||||
## 实现顺序建议
|
||||
|
||||
1. Task 1 → 2 → 3 → 4(后端核心)
|
||||
2. Task 5(前端 API)
|
||||
3. Task 6(LoginView 注册功能)
|
||||
4. Task 7(SettingsView)
|
||||
5. Task 8(路由和侧边栏)
|
||||
6. Task 9(数据库迁移)
|
||||
1010
docs/superpowers/plans/2026-03-20-stats-dashboard-implementation.md
Normal file
1010
docs/superpowers/plans/2026-03-20-stats-dashboard-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
982
docs/superpowers/plans/2026-03-20-stats-dashboard.md
Normal file
982
docs/superpowers/plans/2026-03-20-stats-dashboard.md
Normal file
@@ -0,0 +1,982 @@
|
||||
# Stats Dashboard Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现统计页面,展示系统健康、对话趋势、知识库、看板、社区和个人洞察等6个Tab的指标数据。
|
||||
|
||||
**Architecture:**
|
||||
- 后端:6个统计API端点,按模块分组
|
||||
- 前端:StatsView.vue 包含 6 个 Tab,使用 ECharts 渲染折线图
|
||||
- 数据聚合:SQL GROUP BY date_trunc('day')
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy, ECharts, Vue 3, Element Plus
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── routers/
|
||||
│ └── stats.py # 新建: 统计 API 路由
|
||||
├── services/
|
||||
│ └── stats_service.py # 新建: 统计服务
|
||||
└── schemas/
|
||||
└── stats.py # 新建: 统计 Schema
|
||||
|
||||
frontend/src/
|
||||
├── api/
|
||||
│ └── stats.ts # 新建: 统计 API
|
||||
├── views/
|
||||
│ └── StatsView.vue # 新建: 统计页面
|
||||
└── router/
|
||||
└── index.ts # 修改: 添加 /stats 路由
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create Stats Schema
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/stats.py`
|
||||
|
||||
- [ ] **Step 1: Create stats schemas**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/stats.py
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ===== System Health =====
|
||||
class SystemHealth(BaseModel):
|
||||
uptime_seconds: int
|
||||
cpu_percent: float
|
||||
memory_used_mb: float
|
||||
memory_total_mb: float
|
||||
memory_percent: float
|
||||
disk_used_gb: float
|
||||
disk_total_gb: float
|
||||
disk_percent: float
|
||||
active_users_24h: int
|
||||
|
||||
|
||||
# ===== Daily Stats Base =====
|
||||
class DailyStatItem(BaseModel):
|
||||
date: str
|
||||
count: int
|
||||
|
||||
|
||||
class DailyTokenStatItem(BaseModel):
|
||||
date: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
|
||||
|
||||
# ===== Conversation Stats =====
|
||||
class ConversationStats(BaseModel):
|
||||
daily_conversations: list[DailyStatItem]
|
||||
daily_messages: list[DailyStatItem]
|
||||
daily_input_tokens: list[DailyTokenStatItem]
|
||||
daily_output_tokens: list[DailyTokenStatItem]
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Knowledge Stats =====
|
||||
class KnowledgeStats(BaseModel):
|
||||
daily_new_tags: list[DailyStatItem]
|
||||
daily_documents: list[DailyStatItem]
|
||||
daily_knowledge_queries: list[DailyStatItem]
|
||||
daily_tag_relations: list[DailyStatItem]
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Kanban Stats =====
|
||||
class KanbanStats(BaseModel):
|
||||
daily_new_tasks: list[DailyStatItem]
|
||||
daily_completed_tasks: list[DailyStatItem]
|
||||
daily_completion_rate: list[DailyStatItem]
|
||||
current_pending_tasks: int
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Community Stats =====
|
||||
class CommunityStats(BaseModel):
|
||||
daily_posts: list[DailyStatItem]
|
||||
daily_replies: list[DailyStatItem]
|
||||
daily_ai_executions: list[DailyStatItem]
|
||||
daily_agent_calls: list[DailyStatItem]
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Personal Insights =====
|
||||
class HourlyActivity(BaseModel):
|
||||
hour: int
|
||||
count: int
|
||||
|
||||
|
||||
class TagUsage(BaseModel):
|
||||
tag_path: str
|
||||
usage_count: int
|
||||
|
||||
|
||||
class PersonalInsights(BaseModel):
|
||||
hourly_activity: list[HourlyActivity]
|
||||
top_tags: list[TagUsage]
|
||||
token_trend_percent: float
|
||||
this_month_tokens: int
|
||||
last_month_tokens: int
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create Stats Service
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/stats_service.py`
|
||||
|
||||
- [ ] **Step 1: Create stats service**
|
||||
|
||||
```python
|
||||
# backend/app/services/stats_service.py
|
||||
import psutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.conversation import Conversation, Message
|
||||
from app.models.knowledge_graph import KGNode, KGEdge
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.forum import ForumPost, ForumReply
|
||||
from app.models.document import Document
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class StatsService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_system_health(self) -> dict:
|
||||
"""获取系统健康指标"""
|
||||
# Uptime (假设进程启动时间)
|
||||
uptime_seconds = int(time.time() - psutil.boot_time())
|
||||
|
||||
# CPU
|
||||
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||
|
||||
# Memory
|
||||
mem = psutil.virtual_memory()
|
||||
memory_used_mb = mem.used / (1024 * 1024)
|
||||
memory_total_mb = mem.total / (1024 * 1024)
|
||||
memory_percent = mem.percent
|
||||
|
||||
# Disk
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_used_gb = disk.used / (1024 * 1024 * 1024)
|
||||
disk_total_gb = disk.total / (1024 * 1024 * 1024)
|
||||
disk_percent = disk.percent
|
||||
|
||||
# Active users (24h)
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
active_users = self.db.query(func.count(func.distinct(User.id))).filter(
|
||||
User.updated_at >= yesterday
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory_used_mb": round(memory_used_mb, 1),
|
||||
"memory_total_mb": round(memory_total_mb, 1),
|
||||
"memory_percent": memory_percent,
|
||||
"disk_used_gb": round(disk_used_gb, 1),
|
||||
"disk_total_gb": round(disk_total_gb, 1),
|
||||
"disk_percent": disk_percent,
|
||||
"active_users_24h": active_users,
|
||||
}
|
||||
|
||||
def _get_daily_stats(self, model, date_column, user_id=None, days=30) -> list:
|
||||
"""通用每日统计查询"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
query = self.db.query(
|
||||
func.date(date_column).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(date_column >= cutoff)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(model.user_id == user_id)
|
||||
|
||||
query = query.group_by(func.date(date_column)).order_by(func.date(date_column))
|
||||
results = query.all()
|
||||
return [{"date": str(r.date), "count": r.count} for r in results]
|
||||
|
||||
def get_conversation_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取对话统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Daily conversations
|
||||
daily_conversations = self._get_daily_stats(
|
||||
Conversation, Conversation.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Daily messages
|
||||
daily_messages = self._get_daily_stats(
|
||||
Message, Message.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Daily tokens (input vs output - approximated by role)
|
||||
input_query = self.db.query(
|
||||
func.date(Message.created_at).label('date'),
|
||||
func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
|
||||
).filter(
|
||||
Message.created_at >= cutoff,
|
||||
Message.role == 'user'
|
||||
)
|
||||
if user_id:
|
||||
input_query = input_query.join(Conversation).filter(Conversation.user_id == user_id)
|
||||
input_query = input_query.group_by(func.date(Message.created_at))
|
||||
input_results = input_query.all()
|
||||
|
||||
output_query = self.db.query(
|
||||
func.date(Message.created_at).label('date'),
|
||||
func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
|
||||
).filter(
|
||||
Message.created_at >= cutoff,
|
||||
Message.role == 'assistant'
|
||||
)
|
||||
if user_id:
|
||||
output_query = output_query.join(Conversation).filter(Conversation.user_id == user_id)
|
||||
output_query = output_query.group_by(func.date(Message.created_at))
|
||||
output_results = output_query.all()
|
||||
|
||||
daily_input_tokens = [
|
||||
{"date": str(r.date), "input_tokens": r.tokens}
|
||||
for r in input_results
|
||||
]
|
||||
daily_output_tokens = [
|
||||
{"date": str(r.date), "output_tokens": r.tokens}
|
||||
for r in output_results
|
||||
]
|
||||
|
||||
total_conversations = sum(c["count"] for c in daily_conversations)
|
||||
total_messages = sum(m["count"] for m in daily_messages)
|
||||
total_input = sum(t["input_tokens"] for t in daily_input_tokens)
|
||||
total_output = sum(t["output_tokens"] for t in daily_output_tokens)
|
||||
|
||||
return {
|
||||
"daily_conversations": daily_conversations,
|
||||
"daily_messages": daily_messages,
|
||||
"daily_input_tokens": daily_input_tokens,
|
||||
"daily_output_tokens": daily_output_tokens,
|
||||
"totals": {
|
||||
"conversations": total_conversations,
|
||||
"messages": total_messages,
|
||||
"input_tokens": total_input,
|
||||
"output_tokens": total_output,
|
||||
}
|
||||
}
|
||||
|
||||
def get_knowledge_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取知识库统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# New tags
|
||||
daily_new_tags = self._get_daily_stats(
|
||||
KGNode, KGNode.created_at, user_id, days
|
||||
)
|
||||
# Filter by tag type if user_id provided
|
||||
if user_id:
|
||||
tag_query = self.db.query(
|
||||
func.date(KGNode.created_at).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
KGNode.created_at >= cutoff,
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type == 'tag'
|
||||
).group_by(func.date(KGNode.created_at))
|
||||
daily_new_tags = [{"date": str(r.date), "count": r.count} for r in tag_query.all()]
|
||||
|
||||
# Documents
|
||||
daily_documents = self._get_daily_stats(
|
||||
Document, Document.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Tag relations
|
||||
daily_tag_relations = self._get_daily_stats(
|
||||
KGEdge, KGEdge.created_at, user_id, days
|
||||
)
|
||||
|
||||
return {
|
||||
"daily_new_tags": daily_new_tags,
|
||||
"daily_documents": daily_documents,
|
||||
"daily_knowledge_queries": [], # 需要 Chroma 查询日志
|
||||
"daily_tag_relations": daily_tag_relations,
|
||||
"totals": {
|
||||
"new_tags": sum(t["count"] for t in daily_new_tags),
|
||||
"documents": sum(d["count"] for d in daily_documents),
|
||||
"tag_relations": sum(r["count"] for r in daily_tag_relations),
|
||||
}
|
||||
}
|
||||
|
||||
def get_kanban_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取看板统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# New tasks
|
||||
daily_new_tasks = self._get_daily_stats(
|
||||
Task, Task.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Completed tasks
|
||||
daily_completed = []
|
||||
completed_query = self.db.query(
|
||||
func.date(Task.completed_at).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
Task.completed_at >= cutoff,
|
||||
Task.status == TaskStatus.DONE
|
||||
)
|
||||
if user_id:
|
||||
completed_query = completed_query.filter(Task.user_id == user_id)
|
||||
completed_query = completed_query.group_by(func.date(Task.completed_at))
|
||||
daily_completed = [{"date": str(r.date), "count": r.count} for r in completed_query.all()]
|
||||
|
||||
# Current pending tasks
|
||||
pending_count = self.db.query(func.count(Task.id)).filter(
|
||||
Task.status == TaskStatus.TODO
|
||||
)
|
||||
if user_id:
|
||||
pending_count = pending_count.filter(Task.user_id == user_id)
|
||||
current_pending = pending_count.scalar() or 0
|
||||
|
||||
# Completion rate (daily)
|
||||
daily_new_dict = {d["date"]: d["count"] for d in daily_new_tasks}
|
||||
daily_completed_dict = {d["date"]: d["count"] for d in daily_completed}
|
||||
all_dates = set(daily_new_dict.keys()) | set(daily_completed_dict.keys())
|
||||
daily_completion_rate = []
|
||||
for date in sorted(all_dates):
|
||||
new = daily_new_dict.get(date, 0)
|
||||
completed = daily_completed_dict.get(date, 0)
|
||||
rate = (completed / new * 100) if new > 0 else 0
|
||||
daily_completion_rate.append({"date": date, "rate": round(rate, 1)})
|
||||
|
||||
return {
|
||||
"daily_new_tasks": daily_new_tasks,
|
||||
"daily_completed_tasks": daily_completed,
|
||||
"daily_completion_rate": daily_completion_rate,
|
||||
"current_pending_tasks": current_pending,
|
||||
"totals": {
|
||||
"new_tasks": sum(t["count"] for t in daily_new_tasks),
|
||||
"completed_tasks": sum(c["count"] for c in daily_completed),
|
||||
}
|
||||
}
|
||||
|
||||
def get_community_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取社区统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Posts
|
||||
daily_posts = self._get_daily_stats(
|
||||
ForumPost, ForumPost.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Replies
|
||||
daily_replies = self._get_daily_stats(
|
||||
ForumReply, ForumReply.created_at, user_id, days
|
||||
)
|
||||
|
||||
# AI executions
|
||||
daily_ai_executions = []
|
||||
ai_query = self.db.query(
|
||||
func.date(ForumPost.updated_at).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
ForumPost.updated_at >= cutoff,
|
||||
ForumPost.is_executed == True
|
||||
)
|
||||
if user_id:
|
||||
ai_query = ai_query.filter(ForumPost.user_id == user_id)
|
||||
ai_query = ai_query.group_by(func.date(ForumPost.updated_at))
|
||||
daily_ai_executions = [{"date": str(r.date), "count": r.count} for r in ai_query.all()]
|
||||
|
||||
return {
|
||||
"daily_posts": daily_posts,
|
||||
"daily_replies": daily_replies,
|
||||
"daily_ai_executions": daily_ai_executions,
|
||||
"daily_agent_calls": [], # 需要 AgentMessage 表
|
||||
"totals": {
|
||||
"posts": sum(p["count"] for p in daily_posts),
|
||||
"replies": sum(r["count"] for r in daily_replies),
|
||||
"ai_executions": sum(a["count"] for a in daily_ai_executions),
|
||||
}
|
||||
}
|
||||
|
||||
def get_personal_insights(self, user_id: str) -> dict:
|
||||
"""获取个人洞察"""
|
||||
# Hourly activity
|
||||
hourly_query = self.db.query(
|
||||
func.extract('hour', Conversation.created_at).label('hour'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
Conversation.user_id == user_id
|
||||
).group_by(func.extract('hour', Conversation.created_at))
|
||||
hourly_results = hourly_query.all()
|
||||
hourly_activity = [{"hour": int(r.hour), "count": r.count} for r in hourly_results]
|
||||
|
||||
# Top tags
|
||||
tag_query = self.db.query(
|
||||
KGNode.properties_["tag_path"].astext.label('tag_path'),
|
||||
func.count(KGEdge.id).label('usage_count')
|
||||
).join(
|
||||
KGEdge, KGEdge.target_id == KGNode.id
|
||||
).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type == 'tag',
|
||||
KGEdge.relation_type == 'has_tag'
|
||||
).group_by(
|
||||
KGNode.properties_["tag_path"].astext
|
||||
).order_by(func.count(KGEdge.id).desc()).limit(5)
|
||||
top_tags = [{"tag_path": r.tag_path, "usage_count": r.usage_count} for r in tag_query.all()]
|
||||
|
||||
# Token trend (this month vs last month)
|
||||
now = datetime.utcnow()
|
||||
this_month_start = datetime(now.year, now.month, 1)
|
||||
last_month_end = this_month_start - timedelta(days=1)
|
||||
last_month_start = datetime(last_month_end.year, last_month_end.month, 1)
|
||||
|
||||
this_month_tokens = self.db.query(
|
||||
func.coalesce(func.sum(Message.tokens_used), 0)
|
||||
).join(Conversation).filter(
|
||||
Conversation.user_id == user_id,
|
||||
Message.created_at >= this_month_start,
|
||||
Message.role == 'assistant'
|
||||
).scalar() or 0
|
||||
|
||||
last_month_tokens = self.db.query(
|
||||
func.coalesce(func.sum(Message.tokens_used), 0)
|
||||
).join(Conversation).filter(
|
||||
Conversation.user_id == user_id,
|
||||
Message.created_at >= last_month_start,
|
||||
Message.created_at < this_month_start,
|
||||
Message.role == 'assistant'
|
||||
).scalar() or 0
|
||||
|
||||
token_trend = 0
|
||||
if last_month_tokens > 0:
|
||||
token_trend = round((this_month_tokens - last_month_tokens) / last_month_tokens * 100, 1)
|
||||
|
||||
return {
|
||||
"hourly_activity": hourly_activity,
|
||||
"top_tags": top_tags,
|
||||
"token_trend_percent": token_trend,
|
||||
"this_month_tokens": this_month_tokens,
|
||||
"last_month_tokens": last_month_tokens,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Stats Router
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/stats.py`
|
||||
|
||||
- [ ] **Step 1: Create stats router**
|
||||
|
||||
```python
|
||||
# backend/app/routers/stats.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.stats import (
|
||||
SystemHealth,
|
||||
ConversationStats,
|
||||
KnowledgeStats,
|
||||
KanbanStats,
|
||||
CommunityStats,
|
||||
PersonalInsights,
|
||||
)
|
||||
from app.services.stats_service import StatsService
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["统计"])
|
||||
|
||||
|
||||
@router.get("/system", response_model=SystemHealth)
|
||||
async def get_system_health(db: Session = Depends(get_db)):
|
||||
"""获取系统健康指标"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_system_health()
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=ConversationStats)
|
||||
async def get_conversation_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取对话统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_conversation_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/knowledge", response_model=KnowledgeStats)
|
||||
async def get_knowledge_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取知识库统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_knowledge_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/kanban", response_model=KanbanStats)
|
||||
async def get_kanban_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取看板统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_kanban_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/community", response_model=CommunityStats)
|
||||
async def get_community_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取社区统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_community_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/insights", response_model=PersonalInsights)
|
||||
async def get_personal_insights(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取个人洞察"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_personal_insights(user_id=current_user.id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register router in main app**
|
||||
|
||||
在 `backend/app/__init__.py` 或 `main.py` 中添加:
|
||||
```python
|
||||
from app.routers import stats
|
||||
app.include_router(stats.router)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create Frontend API
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/stats.ts`
|
||||
|
||||
- [ ] **Step 1: Create stats API**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/stats.ts
|
||||
import axios from '@/api'
|
||||
|
||||
export const getSystemHealth = () => axios.get('/stats/system')
|
||||
|
||||
export const getConversationStats = (days = 30) =>
|
||||
axios.get('/stats/conversations', { params: { days } })
|
||||
|
||||
export const getKnowledgeStats = (days = 30) =>
|
||||
axios.get('/stats/knowledge', { params: { days } })
|
||||
|
||||
export const getKanbanStats = (days = 30) =>
|
||||
axios.get('/stats/kanban', { params: { days } })
|
||||
|
||||
export const getCommunityStats = (days = 30) =>
|
||||
axios.get('/stats/community', { params: { days } })
|
||||
|
||||
export const getPersonalInsights = () => axios.get('/stats/insights')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create StatsView Component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/StatsView.vue`
|
||||
|
||||
- [ ] **Step 1: Create StatsView with 6 tabs**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as statsApi from '@/api/stats'
|
||||
import * as echarts from 'echarts'
|
||||
import {
|
||||
Cpu, HardDrive, MemoryStick, Users, Activity,
|
||||
MessageSquare, BookOpen, CheckSquare, Forum,
|
||||
TrendingUp, Clock, Tag, Zap
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const activeTab = ref('system')
|
||||
const systemHealth = ref<any>(null)
|
||||
const conversationStats = ref<any>(null)
|
||||
const knowledgeStats = ref<any>(null)
|
||||
const kanbanStats = ref<any>(null)
|
||||
const communityStats = ref<any>(null)
|
||||
const personalInsights = ref<any>(null)
|
||||
|
||||
// Format uptime
|
||||
function formatUptime(seconds: number) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
return `${days}d ${hours}h ${mins}m`
|
||||
}
|
||||
|
||||
// Chart refs
|
||||
const convChartRef = ref<HTMLElement>()
|
||||
const knowChartRef = ref<HTMLElement>()
|
||||
const kanbanChartRef = ref<HTMLElement>()
|
||||
const communityChartRef = ref<HTMLElement>()
|
||||
const hourlyChartRef = ref<HTMLElement>()
|
||||
|
||||
onMounted(async () => {
|
||||
const [sys, conv, know, kanban, community, insights] = await Promise.all([
|
||||
statsApi.getSystemHealth(),
|
||||
statsApi.getConversationStats(),
|
||||
statsApi.getKnowledgeStats(),
|
||||
statsApi.getKanbanStats(),
|
||||
statsApi.getCommunityStats(),
|
||||
statsApi.getPersonalInsights(),
|
||||
])
|
||||
systemHealth.value = sys.data
|
||||
conversationStats.value = conv.data
|
||||
knowledgeStats.value = know.data
|
||||
kanbanStats.value = kanban.data
|
||||
communityStats.value = community.data
|
||||
personalInsights.value = insights.data
|
||||
|
||||
// Render charts
|
||||
renderLineChart(convChartRef.value, conv.data)
|
||||
renderLineChart(knowChartRef.value, know.data)
|
||||
renderKanbanChart(kanbanChartRef.value, kanban.data)
|
||||
renderLineChart(communityChartRef.value, community.data)
|
||||
renderHourlyChart(hourlyChartRef.value, insights.data)
|
||||
})
|
||||
|
||||
function renderLineChart(el: HTMLElement, data: any) {
|
||||
if (!el || !data) return
|
||||
const chart = echarts.init(el)
|
||||
const dates = data.daily_conversations?.map((d: any) => d.date) || []
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: Object.keys(data).filter(k => k.startsWith('daily_')) },
|
||||
xAxis: { type: 'category', data: dates },
|
||||
yAxis: { type: 'value' },
|
||||
series: Object.entries(data).filter(([k]) => k.startsWith('daily_')).map(([name, values]) => ({
|
||||
name: name.replace('daily_', ''),
|
||||
type: 'line',
|
||||
data: (values as any[]).map((v: any) => v.count || v.input_tokens || v.output_tokens || 0)
|
||||
}))
|
||||
}
|
||||
chart.setOption(option)
|
||||
}
|
||||
|
||||
function renderKanbanChart(el: HTMLElement, data: any) {
|
||||
if (!el || !data) return
|
||||
const chart = echarts.init(el)
|
||||
const dates = [...new Set([
|
||||
...data.daily_new_tasks.map((d: any) => d.date),
|
||||
...data.daily_completed_tasks.map((d: any) => d.date)
|
||||
])].sort()
|
||||
|
||||
option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新建任务', '完成任务'] },
|
||||
xAxis: { type: 'category', data: dates },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '新建任务', type: 'bar', data: dates.map(d => data.daily_new_tasks.find((t: any) => t.date === d)?.count || 0) },
|
||||
{ name: '完成任务', type: 'bar', data: dates.map(d => data.daily_completed_tasks.find((t: any) => t.date === d)?.count || 0) }
|
||||
]
|
||||
}
|
||||
chart.setOption(option)
|
||||
}
|
||||
|
||||
function renderHourlyChart(el: HTMLElement, data: any) {
|
||||
if (!el || !data) return
|
||||
const chart = echarts.init(el)
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const counts = hours.map(h => data.hourly_activity.find((a: any) => a.hour === h)?.count || 0)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: hours.map(h => `${h}:00`) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'bar', data: counts }]
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats-view">
|
||||
<div class="stats-header">
|
||||
<h1>数据统计</h1>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<!-- Tab 1: System Health -->
|
||||
<el-tab-pane label="系统健康" name="system">
|
||||
<div class="metrics-grid" v-if="systemHealth">
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><Clock /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ formatUptime(systemHealth.uptime_seconds) }}</span>
|
||||
<span class="metric-label">运行时间</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><Cpu /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.cpu_percent }}%</span>
|
||||
<span class="metric-label">CPU 使用率</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><MemoryStick /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.memory_percent }}%</span>
|
||||
<span class="metric-label">内存占用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><HardDrive /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.disk_percent }}%</span>
|
||||
<span class="metric-label">磁盘使用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><Users /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.active_users_24h }}</span>
|
||||
<span class="metric-label">活跃用户(24h)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: Conversations -->
|
||||
<el-tab-pane label="对话统计" name="conversations">
|
||||
<div class="chart-container" ref="convChartRef"></div>
|
||||
<div class="totals-row" v-if="conversationStats">
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.conversations }}</span>
|
||||
<span class="total-label">对话总数</span>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.messages }}</span>
|
||||
<span class="total-label">消息总数</span>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.input_tokens }}</span>
|
||||
<span class="total-label">Input Tokens</span>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.output_tokens }}</span>
|
||||
<span class="total-label">Output Tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: Knowledge -->
|
||||
<el-tab-pane label="知识库" name="knowledge">
|
||||
<div class="chart-container" ref="knowChartRef"></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: Kanban -->
|
||||
<el-tab-pane label="看板" name="kanban">
|
||||
<div class="chart-container" ref="kanbanChartRef"></div>
|
||||
<div class="totals-row" v-if="kanbanStats">
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ kanbanStats.current_pending_tasks }}</span>
|
||||
<span class="total-label">待办任务</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 5: Community -->
|
||||
<el-tab-pane label="社区" name="community">
|
||||
<div class="chart-container" ref="communityChartRef"></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 6: Personal Insights -->
|
||||
<el-tab-pane label="个人洞察" name="insights">
|
||||
<div class="insights-grid" v-if="personalInsights">
|
||||
<div class="insight-card">
|
||||
<h3>活跃时段</h3>
|
||||
<div class="chart-small" ref="hourlyChartRef"></div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h3>常用标签 Top5</h3>
|
||||
<ul class="tag-list">
|
||||
<li v-for="tag in personalInsights.top_tags" :key="tag.tag_path">
|
||||
<Tag /> {{ tag.tag_path }} ({{ tag.usage_count }})
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h3>Token 消耗趋势</h3>
|
||||
<div class="trend-value" :class="personalInsights.token_trend_percent > 0 ? 'up' : 'down'">
|
||||
<TrendingUp /> {{ personalInsights.token_trend_percent }}%
|
||||
</div>
|
||||
<p>本月 vs 上月</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-view {
|
||||
padding: 24px;
|
||||
}
|
||||
.stats-header h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bg-panel);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.metric-icon {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.metric-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.totals-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
.total-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.total-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.total-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.insights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.insight-card {
|
||||
background: var(--bg-panel);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.insight-card h3 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chart-small {
|
||||
height: 200px;
|
||||
}
|
||||
.tag-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.tag-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.trend-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.trend-value.up { color: var(--accent-red); }
|
||||
.trend-value.down { color: var(--accent-green); }
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add Route and Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
- Modify: `frontend/src/components/SidebarNav.vue`
|
||||
|
||||
- [ ] **Step 1: Add route**
|
||||
|
||||
```typescript
|
||||
// frontend/src/router/index.ts
|
||||
{
|
||||
path: 'stats',
|
||||
name: 'stats',
|
||||
component: () => import('@/views/StatsView.vue'),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add navigation item**
|
||||
|
||||
```typescript
|
||||
// frontend/src/components/SidebarNav.vue
|
||||
// Add to navItems array:
|
||||
{ name: '统计', path: '/stats', icon: Activity },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | Files |
|
||||
|------|-------------|-------|
|
||||
| 1 | Stats Schema | `schemas/stats.py` |
|
||||
| 2 | Stats Service | `services/stats_service.py` |
|
||||
| 3 | Stats Router | `routers/stats.py` |
|
||||
| 4 | Frontend API | `api/stats.ts` |
|
||||
| 5 | StatsView Component | `views/StatsView.vue` |
|
||||
| 6 | Route & Navigation | `router/index.ts`, `SidebarNav.vue` |
|
||||
739
docs/superpowers/plans/2026-03-20-tag-system.md
Normal file
739
docs/superpowers/plans/2026-03-20-tag-system.md
Normal file
@@ -0,0 +1,739 @@
|
||||
# Tag System Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现一个层级标签系统,标签作为 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` |
|
||||
1611
docs/superpowers/plans/2026-03-21-forum-redesign-implementation.md
Normal file
1611
docs/superpowers/plans/2026-03-21-forum-redesign-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
941
docs/superpowers/plans/2026-03-21-knowledge-folder-plan.md
Normal file
941
docs/superpowers/plans/2026-03-21-knowledge-folder-plan.md
Normal file
@@ -0,0 +1,941 @@
|
||||
# 知识库文件夹分层实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为知识库添加文件夹分层组织功能,支持多层嵌套、CRUD、级联删除
|
||||
|
||||
**Architecture:** 使用邻接表模式(parent_id)存储文件夹层级,通过递归 CTE 查询完整树结构。ChromaDB metadata 增加 folder_path 支持按文件夹过滤检索。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy async + SQLite + ChromaDB + Vue 3 + TypeScript
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 Folder 模型
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/folder.py`
|
||||
- Test: `backend/app/models/test_folder.py`
|
||||
|
||||
- [ ] **Step 1: 创建 Folder 模型**
|
||||
|
||||
```python
|
||||
# backend/app/models/folder.py
|
||||
from sqlalchemy import Column, String, ForeignKey, UniqueConstraint
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class Folder(BaseModel):
|
||||
__tablename__ = "folders"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'parent_id', 'name', name='uq_user_parent_name'),
|
||||
)
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
parent_id = Column(String(36), ForeignKey("folders.id"), nullable=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建测试文件验证模型**
|
||||
|
||||
```python
|
||||
# backend/app/models/test_folder.py
|
||||
import pytest
|
||||
from app.models.folder import Folder
|
||||
|
||||
def test_folder_model_creation():
|
||||
folder = Folder(user_id="test-user", name="Test Folder")
|
||||
assert folder.name == "Test Folder"
|
||||
assert folder.parent_id is None
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建 Folder Schema
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/folder.py`
|
||||
|
||||
- [ ] **Step 1: 创建 Pydantic schemas**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/folder.py
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v):
|
||||
forbidden = '/\\*?:'
|
||||
for c in forbidden:
|
||||
if c in v:
|
||||
raise ValueError(f'Folder name cannot contain: {forbidden}')
|
||||
return v
|
||||
|
||||
class FolderUpdate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
class FolderOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
parent_id: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class FolderTreeOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
parent_id: Optional[str]
|
||||
children: List["FolderTreeOut"] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
# 递归模型需要 forward ref
|
||||
FolderTreeOut.model_rebuild()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 创建文件夹路由
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/folder.py`
|
||||
|
||||
- [ ] **Step 1: 实现文件夹 CRUD 路由**
|
||||
|
||||
```python
|
||||
# backend/app/routers/folder.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models.folder import Folder
|
||||
from app.models.user import User
|
||||
from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
|
||||
|
||||
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
|
||||
"""递归构建文件夹树"""
|
||||
tree = []
|
||||
for folder in folders:
|
||||
if folder.parent_id == parent_id:
|
||||
children = build_folder_tree(folders, folder.id)
|
||||
tree.append(FolderTreeOut(
|
||||
id=folder.id,
|
||||
name=folder.name,
|
||||
parent_id=folder.parent_id,
|
||||
children=children
|
||||
))
|
||||
return tree
|
||||
|
||||
@router.get("", response_model=List[FolderTreeOut])
|
||||
async def get_folders(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""获取用户的完整文件夹树"""
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.user_id == current_user.id)
|
||||
)
|
||||
folders = result.scalars().all()
|
||||
return build_folder_tree(list(folders))
|
||||
|
||||
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_folder(
|
||||
folder_data: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""创建文件夹"""
|
||||
# 验证父文件夹存在且属于当前用户
|
||||
if folder_data.parent_id:
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_data.parent_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="父文件夹不存在")
|
||||
|
||||
# 检查同名文件夹
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.parent_id == folder_data.parent_id,
|
||||
Folder.name == folder_data.name
|
||||
)
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="同名文件夹已存在")
|
||||
|
||||
folder = Folder(
|
||||
user_id=current_user.id,
|
||||
name=folder_data.name,
|
||||
parent_id=folder_data.parent_id
|
||||
)
|
||||
db.add(folder)
|
||||
await db.commit()
|
||||
await db.refresh(folder)
|
||||
return folder
|
||||
|
||||
@router.put("/{folder_id}", response_model=FolderOut)
|
||||
async def rename_folder(
|
||||
folder_id: str,
|
||||
folder_data: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""重命名文件夹"""
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
|
||||
folder.name = folder_data.name
|
||||
await db.commit()
|
||||
await db.refresh(folder)
|
||||
return folder
|
||||
|
||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""删除文件夹(级联删除文档)"""
|
||||
from app.models.document import Document
|
||||
from app.services.knowledge_service import KnowledgeService
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
|
||||
async def delete_recursive(fid: str):
|
||||
# 删除子文件夹(先递归)
|
||||
children = await db.execute(
|
||||
select(Folder).where(Folder.parent_id == fid)
|
||||
)
|
||||
for child in children.scalars():
|
||||
await delete_recursive(child.id)
|
||||
|
||||
# 删除文档
|
||||
docs = await db.execute(
|
||||
select(Document).where(Document.folder_id == fid)
|
||||
)
|
||||
for doc in docs.scalars():
|
||||
knowledge_service = KnowledgeService(db, current_user.id)
|
||||
await knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
|
||||
await db.delete(doc)
|
||||
|
||||
# 删除文件夹本身
|
||||
folder_to_delete = await db.get(Folder, fid)
|
||||
if folder_to_delete:
|
||||
await db.delete(folder_to_delete)
|
||||
|
||||
await delete_recursive(folder_id)
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 修改 Document 模型
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/document.py:14`
|
||||
|
||||
- [ ] **Step 1: 添加 folder_id 外键**
|
||||
|
||||
```python
|
||||
# backend/app/models/document.py 第14行后添加
|
||||
folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # 新增
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 修改 Document 路由和服务
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/document.py`
|
||||
- Modify: `backend/app/services/document_service.py`
|
||||
|
||||
- [ ] **Step 1: 修改 Document 路由**
|
||||
|
||||
在 `routers/document.py` 中:
|
||||
- GET `/api/documents` 添加 `folder_id` 可选查询参数
|
||||
- POST `/api/documents` 添加 `folder_id` 表单字段
|
||||
|
||||
```python
|
||||
# GET /api/documents 修改
|
||||
@router.get("")
|
||||
async def list_documents(
|
||||
folder_id: Optional[str] = None, # 新增
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
query = select(Document).where(Document.user_id == current_user.id)
|
||||
if folder_id:
|
||||
query = query.where(Document.folder_id == folder_id)
|
||||
# ... 其余不变
|
||||
|
||||
# POST /api/documents 修改
|
||||
@router.post("")
|
||||
async def upload_document(
|
||||
file: UploadFile = File(...),
|
||||
folder_id: Optional[str] = Form(None), # 新增
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# ... 文件处理逻辑 ...
|
||||
doc = await doc_svc.upload_document(
|
||||
user_id=current_user.id,
|
||||
file=file,
|
||||
folder_id=folder_id # 传递 folder_id
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 DocumentService.upload_document**
|
||||
|
||||
```python
|
||||
# backend/app/services/document_service.py
|
||||
|
||||
async def upload_document(
|
||||
self,
|
||||
user_id: str,
|
||||
file: UploadFile,
|
||||
folder_id: str | None = None, # 新增
|
||||
) -> Document:
|
||||
# ... 文件保存逻辑 ...
|
||||
|
||||
# 获取文件夹路径(用于 ChromaDB metadata)
|
||||
folder_path = None
|
||||
if folder_id:
|
||||
folder_path = await self._get_folder_path(folder_id)
|
||||
|
||||
# 创建文档记录
|
||||
doc = Document(
|
||||
user_id=user_id,
|
||||
title=filename.rsplit('.', 1)[0],
|
||||
filename=filename,
|
||||
file_type=file_type,
|
||||
file_size=file_size,
|
||||
file_path=file_path,
|
||||
folder_id=folder_id, # 新增
|
||||
)
|
||||
# ... 其余逻辑 ...
|
||||
return doc
|
||||
|
||||
async def _get_folder_path(self, folder_id: str) -> str | None:
|
||||
"""获取文件夹的完整路径"""
|
||||
folders = await self.db.execute(
|
||||
select(Folder).where(Folder.user_id == self.user_id)
|
||||
)
|
||||
folder_map = {f.id: f for f in folders.scalars().all()}
|
||||
|
||||
path_parts = []
|
||||
current_id = folder_id
|
||||
while current_id:
|
||||
folder = folder_map.get(current_id)
|
||||
if not folder:
|
||||
break
|
||||
path_parts.insert(0, folder.name)
|
||||
current_id = folder.parent_id
|
||||
|
||||
return "/" + "/".join(path_parts) if path_parts else None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 修改 Knowledge Service
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/knowledge_service.py`
|
||||
|
||||
- [ ] **Step 1: retrieve 方法添加 folder_id 参数**
|
||||
|
||||
```python
|
||||
# backend/app/services/knowledge_service.py
|
||||
|
||||
# 在 index_document 方法中添加 folder_path 到 metadata
|
||||
async def index_document(self, document_id: str, user_id: str, folder_path: str | None = None):
|
||||
"""将文档 chunks 向量化存入 ChromaDB"""
|
||||
# ... 现有代码 ...
|
||||
|
||||
metadatas = [
|
||||
{
|
||||
"document_id": doc.id,
|
||||
"document_title": doc.title,
|
||||
"chunk_index": chunk.chunk_index,
|
||||
"file_type": doc.file_type,
|
||||
"folder_path": folder_path or "", # 新增
|
||||
}
|
||||
for chunk in chunks
|
||||
]
|
||||
# ... 其余不变
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
folder_id: str | None = None, # 新增
|
||||
top_k: int = 5,
|
||||
use_rerank: bool = True,
|
||||
) -> list[SearchResult]:
|
||||
"""混合检索 + Rerank,支持按文件夹过滤"""
|
||||
collection = self.get_collection(user_id)
|
||||
|
||||
# 构建过滤条件
|
||||
where = None
|
||||
if folder_id:
|
||||
folder_path = await self._get_folder_path(folder_id)
|
||||
if folder_path:
|
||||
where = {"folder_path": {"$starts_with": folder_path}}
|
||||
|
||||
try:
|
||||
results = collection.query(
|
||||
query_texts=[query],
|
||||
n_results=top_k * 3,
|
||||
where=where,
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
# ... 其余不变
|
||||
|
||||
async def _get_folder_path(self, folder_id: str) -> str | None:
|
||||
"""获取文件夹的完整路径"""
|
||||
result = await self.db.execute(
|
||||
select(Folder).where(Folder.id == folder_id)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
return None
|
||||
|
||||
path_parts = [folder.name]
|
||||
current_parent_id = folder.parent_id
|
||||
|
||||
while current_parent_id:
|
||||
parent_result = await self.db.execute(
|
||||
select(Folder).where(Folder.id == current_parent_id)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
break
|
||||
path_parts.insert(0, parent.name)
|
||||
current_parent_id = parent.parent_id
|
||||
|
||||
return "/" + "/".join(path_parts)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 数据库迁移
|
||||
|
||||
**Files:**
|
||||
- 数据库操作: 添加 folders 表,添加 documents.folder_id 列
|
||||
|
||||
- [ ] **Step 1: 执行 SQL 迁移**
|
||||
|
||||
```python
|
||||
# 迁移脚本
|
||||
import asyncio
|
||||
from app.database import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
async def migrate():
|
||||
async with engine.begin() as conn:
|
||||
# 创建 folders 表
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id VARCHAR(36),
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES folders(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# 添加 documents.folder_id 列
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE documents ADD COLUMN folder_id VARCHAR(36)"))
|
||||
except Exception:
|
||||
pass # 列已存在
|
||||
|
||||
asyncio.run(migrate())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 前端 - 创建 Folder API
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/folder.ts`
|
||||
|
||||
- [ ] **Step 1: 创建文件夹 API 客户端**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/folder.ts
|
||||
import api from './index'
|
||||
|
||||
export interface FolderCreate {
|
||||
name: string
|
||||
parent_id?: string | null
|
||||
}
|
||||
|
||||
export interface FolderUpdate {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface FolderItem {
|
||||
id: string
|
||||
name: string
|
||||
parent_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FolderTree {
|
||||
id: string
|
||||
name: string
|
||||
parent_id: string | null
|
||||
children: FolderTree[]
|
||||
}
|
||||
|
||||
export const folderApi = {
|
||||
// 获取文件夹树
|
||||
getTree() {
|
||||
return api.get<FolderTree[]>('/api/folders')
|
||||
},
|
||||
|
||||
// 创建文件夹
|
||||
create(data: FolderCreate) {
|
||||
return api.post('/api/folders', data)
|
||||
},
|
||||
|
||||
// 重命名文件夹
|
||||
rename(id: string, data: FolderUpdate) {
|
||||
return api.put(`/api/folders/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除文件夹
|
||||
delete(id: string) {
|
||||
return api.delete(`/api/folders/${id}`)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 前端 - 修改 Document API
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/document.ts`
|
||||
|
||||
- [ ] **Step 1: 添加 folder_id 到 Document 类型**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/document.ts
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
title: string
|
||||
filename: string
|
||||
file_type: string
|
||||
file_size: number
|
||||
file_path: string
|
||||
summary?: string
|
||||
chunk_count: number
|
||||
is_indexed: boolean
|
||||
folder_id?: string | null // 新增
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 10: 前端 - 创建 FolderTree 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/FolderTree.vue`
|
||||
|
||||
- [ ] **Step 1: 创建递归文件夹树组件**
|
||||
|
||||
```vue
|
||||
<!-- frontend/src/components/FolderTree.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { folderApi, type FolderTree } from '@/api/folder'
|
||||
import { Folder, FolderOpen, ChevronRight, MoreVertical, Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
folders: FolderTree[]
|
||||
selectedId?: string | null
|
||||
onSelect: (folder: FolderTree) => void
|
||||
onCreate: (parentId: string | null) => void
|
||||
onRename: (folder: FolderTree) => void
|
||||
onDelete: (folder: FolderTree) => void
|
||||
}>()
|
||||
|
||||
const expandedIds = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
expandedIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
|
||||
e.preventDefault()
|
||||
// 显示右键菜单
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="folder-tree">
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
class="folder-item"
|
||||
>
|
||||
<div
|
||||
class="folder-row"
|
||||
:class="{ selected: folder.id === selectedId }"
|
||||
@click="props.onSelect(folder)"
|
||||
@contextmenu="handleContextMenu($event, folder)"
|
||||
>
|
||||
<!-- 展开/折叠箭头 -->
|
||||
<button
|
||||
v-if="folder.children?.length"
|
||||
class="expand-btn"
|
||||
@click.stop="toggleExpand(folder.id)"
|
||||
>
|
||||
<ChevronRight
|
||||
:size="12"
|
||||
:class="{ rotated: expandedIds.has(folder.id) }"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="expand-placeholder"></span>
|
||||
|
||||
<!-- 文件夹图标 -->
|
||||
<FolderOpen v-if="expandedIds.has(folder.id)" :size="14" class="folder-icon" />
|
||||
<Folder v-else :size="14" class="folder-icon" />
|
||||
|
||||
<!-- 文件夹名称 -->
|
||||
<span class="folder-name">{{ folder.name }}</span>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="folder-actions">
|
||||
<button @click.stop="props.onCreate(folder.id)" title="添加子文件夹">
|
||||
<Plus :size="12" />
|
||||
</button>
|
||||
<button @click.stop="props.onRename(folder)" title="重命名">
|
||||
<Edit2 :size="12" />
|
||||
</button>
|
||||
<button @click.stop="props.onDelete(folder)" title="删除">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子文件夹(递归) -->
|
||||
<div
|
||||
v-if="folder.children?.length && expandedIds.has(folder.id)"
|
||||
class="folder-children"
|
||||
>
|
||||
<FolderTree
|
||||
:folders="folder.children"
|
||||
:selected-id="selectedId"
|
||||
:on-select="onSelect"
|
||||
:on-create="onCreate"
|
||||
:on-rename="onRename"
|
||||
:on-delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* sci-fi 风格 */
|
||||
.folder-tree {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.folder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.folder-row:hover {
|
||||
background: rgba(0, 245, 212, 0.04);
|
||||
}
|
||||
|
||||
.folder-row.selected {
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expand-placeholder {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
color: var(--accent-amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.folder-row:hover .folder-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.folder-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
border-radius: 3px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.folder-actions button:hover {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
}
|
||||
|
||||
.folder-children {
|
||||
padding-left: 16px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 前端 - 改造 KnowledgeView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
||||
|
||||
- [ ] **Step 1: 添加文件夹侧边栏和交互逻辑**
|
||||
|
||||
主要改动:
|
||||
- 导入 FolderTree 组件
|
||||
- 添加文件夹状态和加载逻辑
|
||||
- 修改上传逻辑(需先选择文件夹)
|
||||
- 添加新建/重命名/删除文件夹的弹窗
|
||||
|
||||
```vue
|
||||
<!-- KnowledgeView.vue 核心逻辑改动 -->
|
||||
<script setup lang="ts">
|
||||
// ... 现有代码 ...
|
||||
|
||||
// 新增
|
||||
import { folderApi, type FolderTree, type FolderCreate } from '@/api/folder'
|
||||
import FolderTreeComponent from '@/components/FolderTree.vue'
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
// 状态
|
||||
const folders = ref<FolderTree[]>([])
|
||||
const selectedFolderId = ref<string | null>(null)
|
||||
|
||||
// 加载文件夹树
|
||||
async function loadFolders() {
|
||||
const res = await folderApi.getTree()
|
||||
folders.value = res.data
|
||||
}
|
||||
|
||||
// 选择文件夹
|
||||
function onSelectFolder(folder: FolderTree) {
|
||||
selectedFolderId.value = folder.id
|
||||
loadDocumentsByFolder(folder.id)
|
||||
}
|
||||
|
||||
// 加载指定文件夹的文档
|
||||
async function loadDocumentsByFolder(folderId: string) {
|
||||
const res = await documentApi.list(folderId)
|
||||
documents.value = res.data
|
||||
}
|
||||
|
||||
// 新建文件夹弹窗
|
||||
const showNewFolderDialog = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderParentId = ref<string | null>(null)
|
||||
|
||||
function openNewFolderDialog(parentId: string | null = null) {
|
||||
newFolderParentId.value = parentId
|
||||
newFolderName.value = ''
|
||||
showNewFolderDialog.value = true
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
await folderApi.create({
|
||||
name: newFolderName.value,
|
||||
parent_id: newFolderParentId.value
|
||||
})
|
||||
await loadFolders()
|
||||
showNewFolderDialog.value = false
|
||||
}
|
||||
|
||||
// 重命名/删除类似...
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="knowledge-view">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<Database :size="20" />
|
||||
<h1>KNOWLEDGE BASE</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn" @click="openNewFolderDialog(null)">
|
||||
<FolderPlus :size="14" /> 新建文件夹
|
||||
</button>
|
||||
<button class="btn primary" @click="triggerUpload">
|
||||
<Upload :size="14" /> 上传文档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主布局:侧边栏 + 内容 -->
|
||||
<div class="main-layout">
|
||||
<!-- 左侧文件夹树 -->
|
||||
<aside class="folder-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<Folder :size="14" />
|
||||
<span>文件夹</span>
|
||||
<button class="add-btn" @click="openNewFolderDialog(null)">
|
||||
<Plus :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="folder-list">
|
||||
<FolderTreeComponent
|
||||
:folders="folders"
|
||||
:selected-id="selectedFolderId"
|
||||
:on-select="onSelectFolder"
|
||||
:on-create="openNewFolderDialog"
|
||||
:on-rename="openRenameDialog"
|
||||
:on-delete="openDeleteDialog"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<main class="content-area">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-panel">...</div>
|
||||
|
||||
<!-- 上传区 -->
|
||||
<div class="upload-zone">...</div>
|
||||
|
||||
<!-- 文档列表 -->
|
||||
<div class="docs-section">...</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 新建文件夹弹窗 -->
|
||||
<div v-if="showNewFolderDialog" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h3>新建文件夹</h3>
|
||||
<input v-model="newFolderName" placeholder="文件夹名称" @keyup.enter="createFolder" />
|
||||
<div class="dialog-actions">
|
||||
<button @click="showNewFolderDialog = false">取消</button>
|
||||
<button class="primary" @click="createFolder">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 12: 集成测试
|
||||
|
||||
- [ ] **Step 1: 测试文件夹 CRUD**
|
||||
- [ ] **Step 2: 测试级联删除**
|
||||
- [ ] **Step 3: 测试上传文档到指定文件夹**
|
||||
- [ ] **Step 4: 测试按文件夹搜索**
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
共 12 个 Task,分 4 个 Phase:
|
||||
- **Phase 1**: 数据层 (Task 1-4)
|
||||
- **Phase 2**: 后端 API (Task 5-7)
|
||||
- **Phase 3**: 前端 (Task 8-11)
|
||||
- **Phase 4**: 测试 (Task 12)
|
||||
83
docs/superpowers/specs/2026-03-20-agent-dashboard-design.md
Normal file
83
docs/superpowers/specs/2026-03-20-agent-dashboard-design.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Agent Dashboard 页面设计规格
|
||||
|
||||
## 概述
|
||||
|
||||
为 Jarvis 系统设计一个 Agent 管理页面,以全息战术投影(Holographic Tactical HUD)风格可视化展示 Master + 4 Sub-Agent 的组织架构,支持查看状态和配置。
|
||||
|
||||
## 视觉风格
|
||||
|
||||
- **主题**:全息战术投影(科幻指挥台)
|
||||
- **背景**:#03050a 深空黑 + 微弱网格线 + 全息扫描线纹理
|
||||
- **节点样式**:半透明玻璃态卡片,悬浮空中,全息光晕边框
|
||||
- **字体**:Orbitron(标题)+ JetBrains Mono(正文)
|
||||
- **配色**:Cyan #00f5d4 主色,Amber #f9a825 强调色,Red #ff4757 危险色
|
||||
|
||||
## 布局结构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ AGENT COMMAND CENTER [刷新] [新增] │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ MASTER CORE │ │
|
||||
│ │ JARVIS 指挥官 │ │
|
||||
│ │ [●] 状态灯 │ │
|
||||
│ └─────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────┼───────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ PLANNER │ │ EXECUTOR │ │LIBRARIAN │ │
|
||||
│ │ [●] │ │ [●] │ │ [●] │ │
|
||||
│ │ 规划者 │ │ 执行者 │ │ 知识官 │ │
|
||||
│ │ 调用:12 │ │ 调用:8 │ │ 调用:5 │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────┐ │
|
||||
│ │ ANALYST │ │
|
||||
│ │ [●] │ │
|
||||
│ │ 分析师 │ │
|
||||
│ │ 调用:3 │ │
|
||||
│ └───────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
|
||||
点击节点 → 右侧滑出配置抽屉
|
||||
```
|
||||
|
||||
## 节点卡片字段
|
||||
|
||||
- 名称(Orbitron)
|
||||
- 角色标签(中文)
|
||||
- 状态灯:绿色脉冲=活跃,灰色=空闲
|
||||
- 角色描述(2行)
|
||||
- 调用次数(今日)
|
||||
- 当前任务摘要
|
||||
|
||||
## 连接线
|
||||
|
||||
- 虚线连接 Master → Sub-Agent
|
||||
- 任务触发时:琥珀色脉冲光点沿路径流向目标节点
|
||||
|
||||
## 配置面板(右侧抽屉 400px)
|
||||
|
||||
- Agent 名称
|
||||
- 角色描述
|
||||
- 系统提示词(textarea)
|
||||
- 启用/停用开关
|
||||
- 保存 / 重置按钮
|
||||
|
||||
## 数据来源
|
||||
|
||||
- 固定结构:前端 `src/data/agents.ts`
|
||||
- 运行时状态:`/api/agents/stats`
|
||||
|
||||
## API 设计
|
||||
|
||||
```
|
||||
GET /api/agents/stats → { agent_id, call_count, current_task, status }
|
||||
GET /api/agents/config/{id} → 返回单个 Agent 完整配置
|
||||
PUT /api/agents/config/{id} → 更新 name/description/system_prompt/enabled
|
||||
```
|
||||
192
docs/superpowers/specs/2026-03-20-chat-enhancement-design.md
Normal file
192
docs/superpowers/specs/2026-03-20-chat-enhancement-design.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# 沟通系统增强设计
|
||||
|
||||
## 1. 概述与目标
|
||||
|
||||
在沟通系统(ChatView)中增加两个功能:
|
||||
1. **文件上传** - 用户可在对话中上传文件,AI 自动理解内容并回复
|
||||
2. **表情包选择器** - 在发送按钮旁添加 emoji 选择面板
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
### 2.1 文件上传
|
||||
|
||||
**前端实现:**
|
||||
- 在 `ChatView.vue` 输入区域添加附件按钮(Paperclip 图标)
|
||||
- 使用 `<input type="file">` 触发文件选择
|
||||
- 支持类型:图片(jpg/png/gif/webp)、文档(pdf/doc/docx/xls/xlsx/ppt/pptx/txt)
|
||||
- 文件大小限制:10MB
|
||||
- 上传时显示进度状态
|
||||
|
||||
**消息气泡展示:**
|
||||
- 文件上传成功后,在对话中显示文件消息气泡
|
||||
- 气泡内容:文件图标 + 文件名 + 文件大小
|
||||
- 点击可下载/预览
|
||||
|
||||
**后端实现:**
|
||||
- 复用现有 `/api/documents/upload` 接口上传文件
|
||||
- 创建 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
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [emoji: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const categories = {
|
||||
smile: { name: '😀', emojis: [...] },
|
||||
gesture: { name: '👍', emojis: [...] },
|
||||
object: { name: '📦', emojis: [...] },
|
||||
symbol: { name: '💬', emojis: [...] }
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4.3 FileMessage 组件(新建)
|
||||
|
||||
用于展示文件消息气泡:
|
||||
- 文件图标(根据类型)
|
||||
- 文件名(可截断)
|
||||
- 文件大小
|
||||
- 下载按钮
|
||||
|
||||
## 5. 错误处理
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 文件类型不支持 | 提示"不支持该文件类型" |
|
||||
| 文件超过10MB | 提示"文件超过10MB限制" |
|
||||
| 上传失败 | 提示"上传失败,请重试",显示重试按钮 |
|
||||
| AI读取文件失败 | AI 回复"无法读取文件内容" |
|
||||
| 网络断开 | 提示"网络连接断开" |
|
||||
|
||||
## 6. 状态定义
|
||||
|
||||
| 状态 | 显示 |
|
||||
|------|------|
|
||||
| 上传中 | 进度环 + 文件名 |
|
||||
| 上传成功 | 文件气泡 |
|
||||
| 上传失败 | 错误图标 + 重试按钮 |
|
||||
| AI 读取中 | AI 思考状态..." |
|
||||
|
||||
## 7. 实现顺序
|
||||
|
||||
1. **Phase 1: 基础 UI**
|
||||
- 添加附件按钮和 Emoji 按钮到输入区域
|
||||
- Emoji 选择器组件
|
||||
- 文件消息气泡组件
|
||||
|
||||
2. **Phase 2: 文件上传**
|
||||
- 前端文件上传逻辑
|
||||
- 消息带 file_ids
|
||||
- 文件气泡展示
|
||||
|
||||
3. **Phase 3: AI 理解文件**
|
||||
- 后端文件内容读取接口
|
||||
- AgentService 支持文件上下文
|
||||
- 测试完整流程
|
||||
|
||||
## 8. 文件结构
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── views/
|
||||
│ └── ChatView.vue # 修改 - 添加附件/Emoji按钮
|
||||
├── components/
|
||||
│ ├── chat/
|
||||
│ │ ├── EmojiPicker.vue # 新建 - Emoji 选择器
|
||||
│ │ └── FileMessage.vue # 新建 - 文件消息气泡
|
||||
│ └── stats/ # 已存在
|
||||
│ └── ...
|
||||
└── api/
|
||||
├── conversation.ts # 修改 - chat 支持 file_ids
|
||||
└── document.ts # 新增 - getDocumentContent
|
||||
|
||||
backend/app/
|
||||
├── routers/
|
||||
│ ├── conversation.py # 修改 - ChatRequest 支持 file_ids
|
||||
│ └── document.py # 修改 - 新增 content 接口
|
||||
├── services/
|
||||
│ └── agent_service.py # 修改 - chat 支持文件上下文
|
||||
└── models/
|
||||
└── conversation.py # 修改 - Message 新增 attachments
|
||||
```
|
||||
178
docs/superpowers/specs/2026-03-20-daily-todo-design.md
Normal file
178
docs/superpowers/specs/2026-03-20-daily-todo-design.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Daily Todo 功能设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
每日待办(Daily Todo)是一个以"天"为维度的任务管理模块,与现有的看板(以项目/多天为维度)形成互补。
|
||||
|
||||
**核心价值:** AI 每天早上自动预生成今日待办(基于前一天未完成的看板任务 + 前一天对话记录),用户可手动增删改。
|
||||
|
||||
## 时区说明
|
||||
|
||||
- 所有日期相关字段均使用**用户本地日期**(后端统一用 `datetime.date.today()` 计算,不依赖 UTC)
|
||||
- `todo_date` 格式:`YYYY-MM-DD`(本地日期字符串),便于按天查询
|
||||
|
||||
## 数据模型
|
||||
|
||||
### DailyTodo 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | String(36) | 主键,UUID |
|
||||
| user_id | String(36) | 所属用户,索引 |
|
||||
| title | String(500) | 待办标题 |
|
||||
| is_completed | Boolean | 是否完成,默认 false |
|
||||
| source | Enum | `ai_kanban` / `ai_chat` / `manual`,来源 |
|
||||
| source_detail | String(500) | 展示用说明文本,如"看板:完成用户登录功能" |
|
||||
| source_ref_id | String(36) | 来源原始ID(看板TaskID或对话ConversationID),可空 |
|
||||
| todo_date | String(10) | 所属日期,格式 YYYY-MM-DD,复合索引 (user_id, todo_date) |
|
||||
| completed_at | DateTime | 完成时间,可空 |
|
||||
| created_at | DateTime | 创建时间 |
|
||||
| updated_at | DateTime | 更新时间 |
|
||||
|
||||
**索引:** `INDEX (user_id, todo_date)`,查询今日待办的主要路径
|
||||
|
||||
### DailyTodoHistory 归档表
|
||||
|
||||
归档时机:每天凌晨 1: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)
|
||||
@@ -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 助理系统的初始设计规格,将根据开发进展持续更新。*
|
||||
@@ -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 消耗统计
|
||||
249
docs/superpowers/specs/2026-03-20-settings-register-design.md
Normal file
249
docs/superpowers/specs/2026-03-20-settings-register-design.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# 注册界面 + 设置界面 功能设计
|
||||
|
||||
## 概述
|
||||
|
||||
为 Jarvis 系统添加用户注册功能和完整的设置界面。用户可以:
|
||||
- 在前端注册账号
|
||||
- 在设置界面管理个人信息和 LLM 配置
|
||||
- 配置定时任务等系统参数
|
||||
|
||||
**核心价值:** 支持多用户、每个用户独立配置自己的 LLM 提供商和参数。
|
||||
|
||||
## 现状分析
|
||||
|
||||
### 已有的功能
|
||||
- 后端已有 `/api/auth/register` API
|
||||
- 后端使用 `pydantic-settings` 从 `.env` 读取配置
|
||||
- 前端只有登录页面,无注册入口
|
||||
|
||||
### 需要改动的地方
|
||||
- 前端 LoginView 添加注册表单
|
||||
- User 模型增加 `llm_config` 和 `scheduler_config` JSON 字段
|
||||
- 新建 Settings 路由和服务
|
||||
- 新建 SettingsView 页面
|
||||
|
||||
## 数据模型
|
||||
|
||||
### User 表扩展
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN llm_config TEXT;
|
||||
ALTER TABLE users ADD COLUMN scheduler_config TEXT;
|
||||
```
|
||||
|
||||
### 字段结构
|
||||
|
||||
**llm_config (JSON):**
|
||||
```json
|
||||
{
|
||||
"chat": {
|
||||
"provider": "openai|claude|ollama|deepseek|custom",
|
||||
"model": "gpt-4o",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-..."
|
||||
},
|
||||
"vlm": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"base_url": "...",
|
||||
"api_key": "..."
|
||||
},
|
||||
"embedding": {
|
||||
"provider": "openai",
|
||||
"model": "text-embedding-3-small",
|
||||
"base_url": "...",
|
||||
"api_key": "..."
|
||||
},
|
||||
"rerank": {
|
||||
"provider": "openai",
|
||||
"model": "bge-reranker-v2",
|
||||
"base_url": "...",
|
||||
"api_key": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**scheduler_config (JSON):**
|
||||
```json
|
||||
{
|
||||
"daily_plan_time": "08:00",
|
||||
"forum_scan_interval_minutes": 30,
|
||||
"todo_ai_generate_time": "08:00",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
## API 设计
|
||||
|
||||
### 1. 注册 API (已有)
|
||||
```
|
||||
POST /api/auth/register
|
||||
Body: { email, password, full_name }
|
||||
Response: UserOut
|
||||
```
|
||||
|
||||
### 2. 获取用户设置
|
||||
```
|
||||
GET /api/settings
|
||||
Response: {
|
||||
profile: { id, email, full_name, created_at },
|
||||
llm_config: { ... },
|
||||
scheduler_config: { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 更新用户资料
|
||||
```
|
||||
PUT /api/settings/profile
|
||||
Body: { full_name?, password? }
|
||||
Response: UserOut
|
||||
```
|
||||
|
||||
### 4. 更新 LLM 配置
|
||||
```
|
||||
PUT /api/settings/llm
|
||||
Body: { chat?: {...}, vlm?: {...}, embedding?: {...}, rerank?: {...} }
|
||||
Response: { llm_config: { ... } } // 返回更新后的完整配置
|
||||
```
|
||||
|
||||
### 5. 测试 LLM 连接
|
||||
```
|
||||
POST /api/settings/llm/test
|
||||
Body: { type: "chat"|"vlm"|"embedding"|"rerank", provider, model, base_url, api_key }
|
||||
Response: { success: true, message: "连接成功" } 或 { success: false, error: "错误信息" }
|
||||
```
|
||||
|
||||
### 6. 更新定时任务配置
|
||||
```
|
||||
PUT /api/settings/scheduler
|
||||
Body: { daily_plan_time?, forum_scan_interval_minutes?, todo_ai_generate_time?, enabled? }
|
||||
Response: { scheduler_config: { ... } } // 返回更新后的完整配置
|
||||
```
|
||||
|
||||
## 前端页面
|
||||
|
||||
### LoginView.vue 改动
|
||||
- 添加"注册"和"登录"切换 Tab
|
||||
- 注册表单:邮箱、密码、确认密码、用户名
|
||||
- 复用现有 sci-fi 登录风格
|
||||
|
||||
### SettingsView.vue (新建)
|
||||
|
||||
#### 页面布局
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [⚙] SETTINGS │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ PROFILE │ │
|
||||
│ │ Email: operator@jarvis.ai │ │
|
||||
│ │ Name: [___________] │ │
|
||||
│ │ Password: [********] [Change] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ LLM CONFIGURATION │ │
|
||||
│ │ ┌─ Chat ────────────────────────────────┐ │ │
|
||||
│ │ │ Provider: [OpenAI ▼] │ │ │
|
||||
│ │ │ Model: [gpt-4o ____________] │ │ │
|
||||
│ │ │ Base URL:[https://...] ] │ │ │
|
||||
│ │ │ API Key: [•••••••••••••••••] │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ │ ┌─ VLM ─────────────────────────────────┐ │ │
|
||||
│ │ │ ... (同上结构) │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ │ ┌─ Embedding ───────────────────────────┐ │ │
|
||||
│ │ │ ... (同上结构) │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ │ ┌─ Rerank ──────────────────────────────┐ │ │
|
||||
│ │ │ ... (同上结构) │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ SCHEDULER │ │
|
||||
│ │ Daily Plan Time: [08:00] │ │
|
||||
│ │ Forum Scan Interval: [30] 分钟 │ │
|
||||
│ │ Todo AI Generate: [08:00] │ │
|
||||
│ │ Scheduler Enabled: [ON] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ [SAVE ALL SETTINGS] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 交互行为
|
||||
- 修改后点击"保存"按钮,按钮显示 loading 状态
|
||||
- 保存成功显示 toast 提示"保存成功"
|
||||
- 保存失败显示 toast 提示错误信息
|
||||
- 密码修改需二次确认弹窗
|
||||
- API Key 字段支持显示/隐藏切换
|
||||
- 每个模型配置卡片有独立的"测试"按钮
|
||||
- Provider 切换时自动填充默认值(如 Ollama 切换到 localhost:11434)
|
||||
- Scheduler enabled 关闭时,时间输入框显示禁用状态
|
||||
- 空配置时显示"点击配置"占位提示
|
||||
|
||||
#### 注册表单
|
||||
- 邮箱:必填,格式校验
|
||||
- 用户名:必填,2-20 字符
|
||||
- 密码:必填,最少 8 字符
|
||||
- 确认密码:必填,需与密码一致
|
||||
- 密码强度指示器(弱/中/强)
|
||||
|
||||
## 路由和侧边栏
|
||||
|
||||
### router/index.ts
|
||||
```typescript
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
}
|
||||
```
|
||||
|
||||
### SidebarNav.vue
|
||||
```typescript
|
||||
{ name: '设置', path: '/settings', icon: Settings }
|
||||
```
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 后端文件
|
||||
```
|
||||
backend/app/
|
||||
models/
|
||||
user.py # 修改:添加 llm_config, scheduler_config 字段
|
||||
schemas/
|
||||
auth.py # 修改:UserCreate 支持 full_name
|
||||
settings.py # 新建:SettingsOut, LLMConfigIn, SchedulerConfigIn
|
||||
routers/
|
||||
settings.py # 新建:settings router
|
||||
services/
|
||||
settings_service.py # 新建:设置逻辑服务
|
||||
```
|
||||
|
||||
### 前端文件
|
||||
```
|
||||
frontend/src/
|
||||
api/
|
||||
settings.ts # 新建:settings API 客户端
|
||||
views/
|
||||
LoginView.vue # 修改:添加注册 Tab
|
||||
SettingsView.vue # 新建:设置页面
|
||||
router/
|
||||
index.ts # 修改:添加 /settings 路由
|
||||
components/
|
||||
SidebarNav.vue # 修改:添加设置菜单
|
||||
```
|
||||
|
||||
## 验证清单
|
||||
|
||||
1. 注册功能正常 - 可以通过注册页面创建新账号
|
||||
2. 登录功能正常 - 新老用户都可以登录
|
||||
3. 设置页面可访问 - 登录后可进入设置页面
|
||||
4. 个人信息修改正常 - 用户名、密码可修改
|
||||
5. LLM 配置保存正常 - 四种模型配置可保存
|
||||
6. LLM 测试连接正常 - 可以验证配置是否正确
|
||||
7. 定时任务配置正常 - 时间间隔可修改
|
||||
8. 配置持久化正常 - 重新登录后配置保留
|
||||
9. UI 风格一致 - 设置页面与其他页面风格统一
|
||||
10. 注册表单验证正常 - 密码强度、格式校验有效
|
||||
267
docs/superpowers/specs/2026-03-20-stats-dashboard-design.md
Normal file
267
docs/superpowers/specs/2026-03-20-stats-dashboard-design.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# 数据统计页面重新设计
|
||||
|
||||
## 1. 概述与目标
|
||||
|
||||
重新设计数据统计页面,使其与项目现有的深色赛博朋克/终端风格保持一致。采用单页垂直滚动布局,集成迷你图表,提供清晰的数据可视化。
|
||||
|
||||
## 2. 设计语言
|
||||
|
||||
### 视觉风格
|
||||
- **主题**:深色赛博朋克 + 终端美学
|
||||
- **背景**:`var(--bg-void)` 深空黑
|
||||
- **强调色**:青色 `#00f5d4` (现有变量 `var(--accent-cyan)`)
|
||||
- **辅助色**:紫色 `#a855f7` (用于知识库等模块)
|
||||
- **卡片背景**:`rgba(13,21,37,0.8)` 半透明深蓝
|
||||
- **边框**:`1px solid var(--border-dim)`,hover时发光
|
||||
|
||||
### 字体
|
||||
- **数字**:等宽字体 `var(--font-mono)`,大号加粗,带发光效果
|
||||
- **标签**:`var(--font-display)`,9-10px,字母间距 0.15em
|
||||
- **正文**:`var(--font-mono)`,12-13px
|
||||
|
||||
### 动效
|
||||
- 卡片 hover:边框发光 + 微弱上浮
|
||||
- 数字:首次加载时淡入
|
||||
- 图表:绘制动画 300ms
|
||||
|
||||
## 3. 页面结构
|
||||
|
||||
### 单页垂直滚动布局(无 Tabs)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ // DATA METRICS [页面标题] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [SYSTEM HEALTH] 系统健康模块 │
|
||||
│ [CONVERSATIONS] 对话统计模块 │
|
||||
│ [KNOWLEDGE] 知识库模块 │
|
||||
│ [KANBAN] 看板模块 │
|
||||
│ [COMMUNITY] 社区模块 │
|
||||
│ [INSIGHTS] 个人洞察模块 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 模块详细设计
|
||||
|
||||
### 4.1 系统健康 (SYSTEM HEALTH)
|
||||
|
||||
**位置**:页面最顶部,无需认证即可访问
|
||||
|
||||
**卡片布局**:4列网格
|
||||
|
||||
**指标卡片**:
|
||||
| 指标 | 图标 | 格式 |
|
||||
|------|------|------|
|
||||
| CPU 使用率 | Cpu | 45% + 7天迷你柱状图 |
|
||||
| 内存占用 | MemoryStick | 62% + 7天迷你柱状图 |
|
||||
| 磁盘使用 | HardDrive | 38% + 7天迷你柱状图 |
|
||||
| 运行时间 | Clock | 7d 3h 20m |
|
||||
|
||||
**卡片样式**:
|
||||
- 尺寸:自适应,最小 160px
|
||||
- 数字大小:24px,等宽加粗
|
||||
- 趋势图:高度 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. 数据刷新
|
||||
|
||||
- 页面进入时加载所有数据
|
||||
- 支持手动刷新按钮(每个模块独立刷新)
|
||||
- 数字变化时无动画(避免干扰)
|
||||
473
docs/superpowers/specs/2026-03-21-forum-redesign-design.md
Normal file
473
docs/superpowers/specs/2026-03-21-forum-redesign-design.md
Normal file
@@ -0,0 +1,473 @@
|
||||
# 交互广场重新设计
|
||||
|
||||
## 1. 概述与目标
|
||||
|
||||
将现有的论坛(交互广场)从传统的帖子/回复模式,重构为三个AI驱动的智能板块:
|
||||
1. **AI学习板块** - 模型分析用户活动,学习客观知识并加入知识图谱,向用户汇报学习成果
|
||||
2. **AI建议板块** - 基于用户习惯和数据,提供个性化建议
|
||||
3. **AI交互板块** - 用户发起学习主题,或AI主动探索补充知识
|
||||
|
||||
## 2. 设计风格
|
||||
|
||||
沿用项目现有的深色赛博朋克/终端风格:
|
||||
- 背景:`var(--bg-void)` 深空黑
|
||||
- 强调色:紫色 `#a855f7` (用于交互广场专属色调)
|
||||
- 卡片背景:`var(--bg-card)`
|
||||
- 边框:`1px solid var(--border-dim)`,hover时发光
|
||||
- 字体:等宽字体 `var(--font-mono)`,标题用 `var(--font-display)`
|
||||
|
||||
## 3. 页面结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ // INTERACTIVE PLAZA [页面标题] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [MODEL LEARNING] AI学习板块 │ │
|
||||
│ │ AI分析你的活动,学习知识并汇报 │ │
|
||||
│ ├─────────────────────────────────────────────────────┤ │
|
||||
│ │ • 今日学习摘要 │ │
|
||||
│ │ • 学习历史时间线 │ │
|
||||
│ │ • 知识图谱更新统计 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [SUGGESTIONS] AI建议板块 │ │
|
||||
│ │ 基于你的习惯提供个性化建议 │ │
|
||||
│ ├─────────────────────────────────────────────────────┤ │
|
||||
│ │ • 知识补充建议 │ │
|
||||
│ │ • 效率优化建议 │ │
|
||||
│ │ • 技能深耕建议 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [INTERACTIVE] AI交互学习板块 │ │
|
||||
│ │ 用户发起学习主题,AI主动探索 │ │
|
||||
│ ├─────────────────────────────────────────────────────┤ │
|
||||
│ │ • 用户发起的学习主题 │ │
|
||||
│ │ • AI主动学习的内容 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 功能详情
|
||||
|
||||
### 4.1 AI学习板块 (MODEL LEARNING)
|
||||
|
||||
**数据来源:**
|
||||
- 对话记录(`messages` 表,Message模型)- 分析对话内容提取概念
|
||||
- 看板任务(`tasks` 表,Task模型)- 识别技术栈和工作流程
|
||||
- 知识库(`documents`, `kg_nodes` 表)- 补充知识缺口
|
||||
|
||||
**学习流程:**
|
||||
```
|
||||
定时任务触发 → 分析近期活动 → 提取概念/术语/事实
|
||||
→ 存入知识图谱(KGNode) → 生成学习报告 → 存入learning_records表
|
||||
```
|
||||
|
||||
**数据库扩展:**
|
||||
|
||||
```python
|
||||
# 新增 learning_records 表
|
||||
# 继承 app.models.base.BaseModel,自动获得 id, created_at, updated_at
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class LearningRecord(BaseModel):
|
||||
__tablename__ = "learning_records"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
learning_type = Column(String(50), nullable=False) # concept, technology, workflow
|
||||
topic = Column(String(500), nullable=False) # 学习主题
|
||||
summary = Column(Text, nullable=False) # AI生成的学习摘要
|
||||
source = Column(String(50), nullable=False) # conversation, kanban, knowledge
|
||||
source_ids = Column(JSON, nullable=True) # 来源ID列表,如 {conversation_ids: [...], task_ids: [...]}
|
||||
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
|
||||
```
|
||||
|
||||
**KGNode实体类型扩展:**
|
||||
- `learned_concept` - 从对话中学到的概念
|
||||
- `technology` - 识别出的技术栈
|
||||
- `workflow` - 从看板任务中提取的工作流程
|
||||
|
||||
**前端展示:**
|
||||
|
||||
1. **今日学习摘要卡片**
|
||||
- AI生成的自然语言总结
|
||||
- 示例:"今日学习了依赖注入和异步编程两个概念,它们都来自你关于FastAPI的讨论"
|
||||
- 显示来源标签:对话/看板/知识库
|
||||
|
||||
2. **学习历史时间线**
|
||||
- 垂直时间线布局
|
||||
- 每条记录显示:时间、主题、摘要
|
||||
- 点击展开查看详情
|
||||
|
||||
3. **知识图谱更新统计**
|
||||
- 今日新增节点数
|
||||
- 今日新建关系数
|
||||
- 迷你柱状图显示各类别占比(可复用 MiniBarChart)
|
||||
|
||||
### 4.2 AI建议板块 (SUGGESTIONS)
|
||||
|
||||
**建议类型:**
|
||||
|
||||
1. **知识补充建议 (knowledge)**
|
||||
- 检测知识图谱薄弱领域
|
||||
- 基于用户提问推断知识缺口
|
||||
- 示例:"你的知识图谱在'微服务架构'领域较为薄弱,建议深入学习"
|
||||
|
||||
2. **效率优化建议 (efficiency)**
|
||||
- 分析用户使用模式
|
||||
- 推荐最佳实践
|
||||
- 示例:"你通常在下午工作效率最高,建议将复杂任务安排在这个时段"
|
||||
|
||||
3. **技能深耕建议 (skill)**
|
||||
- 基于高频话题
|
||||
- 推荐深入学习方向
|
||||
- 示例:"你最近频繁讨论API设计,建议学习REST最佳实践和GraphQL"
|
||||
|
||||
**数据库扩展:**
|
||||
|
||||
```python
|
||||
# 新增 suggestions 表
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class Suggestion(BaseModel):
|
||||
__tablename__ = "suggestions"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
suggestion_type = Column(String(50), nullable=False) # knowledge, efficiency, skill
|
||||
title = Column(String(500), nullable=False) # 建议标题
|
||||
content = Column(Text, nullable=False) # 建议内容
|
||||
source_data = Column(JSON, nullable=True) # 分析依据,如 {knowledge_gaps: [...], usage_patterns: {...}}
|
||||
is_read = Column(Boolean, default=False) # 是否已读
|
||||
is_dismissed = Column(Boolean, default=False) # 是否忽略
|
||||
```
|
||||
|
||||
**前端展示:**
|
||||
- 卡片列表布局
|
||||
- 每个建议显示:图标、类型标签、标题、内容
|
||||
- 右侧显示建议来源分析
|
||||
- 提供"查看详情"和"忽略"按钮
|
||||
|
||||
### 4.3 AI交互板块 (INTERACTIVE)
|
||||
|
||||
**用户发起学习:**
|
||||
|
||||
1. 用户输入想学习的主题
|
||||
2. AI分析主题,搜索知识库
|
||||
3. 如有需要,AI主动抓取外部资源
|
||||
4. 生成学习报告
|
||||
5. 自动存入知识图谱
|
||||
6. 在交互板块展示
|
||||
|
||||
**数据库扩展:**
|
||||
|
||||
```python
|
||||
# 新增 interactive_topics 表
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class InteractiveTopic(BaseModel):
|
||||
__tablename__ = "interactive_topics"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
topic = Column(String(500), nullable=False) # 学习主题
|
||||
status = Column(String(50), nullable=False) # pending, learning, completed, failed
|
||||
result = Column(Text, nullable=True) # 学习结果/报告
|
||||
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
|
||||
source = Column(String(50), nullable=False) # user_initiated, ai_proactive
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
```
|
||||
|
||||
**AI主动学习:**
|
||||
|
||||
1. AI分析用户历史提问
|
||||
2. 发现知识缺口或关联主题
|
||||
3. 主动学习并生成报告
|
||||
4. 在交互板块标记为"AI主动"
|
||||
|
||||
**前端展示:**
|
||||
- 两个子区块:用户发起 / AI主动
|
||||
- 输入框:"让AI学习 [主题]"
|
||||
- 正在进行的学习任务显示进度
|
||||
- 已完成的学习显示结果摘要
|
||||
|
||||
## 5. API 设计
|
||||
|
||||
### 5.1 后端接口
|
||||
|
||||
```
|
||||
GET /api/forum/learning/summary
|
||||
- 获取今日学习摘要
|
||||
- 返回: { summary, records[], stats{ nodes_created, edges_created } }
|
||||
|
||||
GET /api/forum/learning/history?page=1&limit=20
|
||||
- 获取学习历史
|
||||
- 返回: { records[], total }
|
||||
|
||||
GET /api/forum/suggestions
|
||||
- 获取所有建议
|
||||
- 返回: { suggestions[] }
|
||||
|
||||
GET /api/forum/suggestions/{id}
|
||||
- 获取单个建议详情
|
||||
- 返回: Suggestion
|
||||
|
||||
PATCH /api/forum/suggestions/{id}/read
|
||||
- 标记建议为已读
|
||||
|
||||
DELETE /api/forum/suggestions/{id}/dismiss
|
||||
- 忽略/删除建议
|
||||
|
||||
GET /api/forum/interactive/topics
|
||||
- 获取交互主题列表
|
||||
- 返回: { user_initiated[], ai_proactive[] }
|
||||
|
||||
POST /api/forum/interactive/learn
|
||||
- 用户发起学习
|
||||
- Body: { topic: string }
|
||||
- 返回: { topic_id, status }
|
||||
|
||||
GET /api/forum/interactive/topics/{id}
|
||||
- 获取学习主题详情/结果
|
||||
```
|
||||
|
||||
### 5.2 前端API
|
||||
|
||||
```typescript
|
||||
// TypeScript 类型定义
|
||||
interface LearningSummary {
|
||||
summary: string
|
||||
records: LearningRecord[]
|
||||
stats: {
|
||||
nodes_created: number
|
||||
edges_created: number
|
||||
}
|
||||
}
|
||||
|
||||
interface LearningRecord {
|
||||
id: string
|
||||
learning_type: 'concept' | 'technology' | 'workflow'
|
||||
topic: string
|
||||
summary: string
|
||||
source: string
|
||||
source_ids?: { conversation_ids?: string[]; task_ids?: string[] }
|
||||
kg_nodes_created?: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
id: string
|
||||
suggestion_type: 'knowledge' | 'efficiency' | 'skill'
|
||||
title: string
|
||||
content: string
|
||||
source_data?: Record<string, any>
|
||||
is_read: boolean
|
||||
is_dismissed: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface InteractiveTopic {
|
||||
id: string
|
||||
topic: string
|
||||
status: 'pending' | 'learning' | 'completed' | 'failed'
|
||||
result?: string
|
||||
kg_nodes_created?: string[]
|
||||
source: 'user_initiated' | 'ai_proactive'
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
// API 方法
|
||||
const forumApi = {
|
||||
// learning
|
||||
fetchLearningSummary(): Promise<LearningSummary>,
|
||||
fetchLearningHistory(params: { page: number, limit: number }): Promise<{ records: LearningRecord[], total: number }>,
|
||||
|
||||
// suggestions
|
||||
fetchSuggestions(): Promise<Suggestion[]>,
|
||||
getSuggestion(id: string): Promise<Suggestion>,
|
||||
markSuggestionRead(id: string): Promise<void>,
|
||||
dismissSuggestion(id: string): Promise<void>,
|
||||
|
||||
// interactive
|
||||
fetchInteractiveTopics(): Promise<{ user_initiated: InteractiveTopic[], ai_proactive: InteractiveTopic[] }>,
|
||||
initiateLearning(topic: string): Promise<InteractiveTopic>,
|
||||
getTopicDetail(id: string): Promise<InteractiveTopic>,
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 组件结构
|
||||
|
||||
```
|
||||
frontend/src/views/ForumView.vue # 主页面,三板块布局
|
||||
frontend/src/components/forum/
|
||||
├── LearningSection.vue # AI学习板块
|
||||
│ ├── LearningSummaryCard.vue # 今日摘要卡片
|
||||
│ ├── LearningTimeline.vue # 学习历史时间线
|
||||
│ └── LearningStats.vue # 图谱更新统计(复用MiniBarChart)
|
||||
├── SuggestionSection.vue # AI建议板块
|
||||
│ ├── SuggestionCard.vue # 建议卡片
|
||||
│ └── SuggestionList.vue # 建议列表
|
||||
└── InteractiveSection.vue # AI交互板块
|
||||
├── LearningInput.vue # 学习主题输入框
|
||||
├── UserInitiatedList.vue # 用户发起列表
|
||||
└── AIProactiveList.vue # AI主动列表
|
||||
|
||||
# 新增通用组件
|
||||
frontend/src/components/forum/MiniDonutChart.vue # 环形图(用于知识类别占比)
|
||||
```
|
||||
|
||||
## 7. 服务层
|
||||
|
||||
### 7.1 LearningService
|
||||
|
||||
```python
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
class LearningService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.llm = get_llm_client()
|
||||
|
||||
async def generate_daily_summary(user_id: str) -> str:
|
||||
"""分析用户今日活动,生成学习摘要"""
|
||||
# 使用 LLM 分析提取的概念,生成自然语言摘要
|
||||
concepts = await self.extract_concepts(...)
|
||||
prompt = f"根据以下学习内容生成简短摘要:{concepts}"
|
||||
return await self.llm.chat(prompt)
|
||||
|
||||
async def extract_concepts_from_conversations(user_id: str, since: datetime) -> list[dict]:
|
||||
"""从对话中提取概念"""
|
||||
|
||||
async def identify_technologies_from_kanban(user_id: str) -> list[dict]:
|
||||
"""从看板任务中识别技术栈"""
|
||||
|
||||
async def create_kg_nodes(user_id: str, learnings: list[dict]) -> list[str]:
|
||||
"""创建知识图谱节点"""
|
||||
|
||||
async def record_learning(...) -> LearningRecord:
|
||||
"""记录学习成果"""
|
||||
```
|
||||
|
||||
### 7.2 SuggestionService
|
||||
|
||||
```python
|
||||
class SuggestionService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.llm = get_llm_client()
|
||||
|
||||
async def generate_suggestions(user_id: str) -> list[Suggestion]:
|
||||
"""生成个性化建议"""
|
||||
# 分析知识缺口、使用模式、技能机会
|
||||
gaps = await self.analyze_knowledge_gaps(user_id)
|
||||
patterns = await self.analyze_usage_patterns(user_id)
|
||||
skills = await self.analyze_skill_opportunities(user_id)
|
||||
|
||||
# 使用 LLM 生成建议
|
||||
prompt = f"基于以下分析生成建议:知识缺口{gaps},使用模式{patterns},技能机会{skills}"
|
||||
return await self.llm.chat(prompt)
|
||||
|
||||
async def analyze_knowledge_gaps(user_id: str) -> list[dict]:
|
||||
"""分析知识图谱缺口"""
|
||||
|
||||
async def analyze_usage_patterns(user_id: str) -> dict:
|
||||
"""分析使用模式"""
|
||||
|
||||
async def identify_skill_opportunities(user_id: str) -> list[dict]:
|
||||
"""识别技能提升机会"""
|
||||
```
|
||||
|
||||
### 7.3 InteractiveService
|
||||
|
||||
```python
|
||||
class InteractiveService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.llm = get_llm_client()
|
||||
|
||||
async def initiate_learning(user_id: str, topic: str) -> InteractiveTopic:
|
||||
"""用户发起学习"""
|
||||
|
||||
async def execute_learning(topic_id: str) -> dict:
|
||||
"""执行学习任务:
|
||||
1. 搜索知识库相关节点
|
||||
2. 使用 LLM 深入学习主题
|
||||
3. 生成学习报告
|
||||
4. 创建 KGNode
|
||||
5. 更新 topic 状态
|
||||
"""
|
||||
topic = await self.get_topic(topic_id)
|
||||
content = await self.research_topic(topic.topic)
|
||||
report = await self.generate_learning_report(topic, content)
|
||||
await self.create_kg_nodes_from_report(report)
|
||||
await self.update_topic_status(topic_id, 'completed', report)
|
||||
|
||||
async def generate_learning_report(self, topic: InteractiveTopic, content: str) -> str:
|
||||
"""使用 LLM 生成结构化学习报告"""
|
||||
```
|
||||
|
||||
## 8. 定时任务
|
||||
|
||||
每日凌晨生成学习报告:
|
||||
- 分析昨日用户活动
|
||||
- 提取新概念和技术栈
|
||||
- 更新知识图谱
|
||||
- 生成学习摘要存入数据库
|
||||
|
||||
**集成方式:** 使用项目现有的 `scheduler_service.py`
|
||||
|
||||
```python
|
||||
# 在 scheduler_service.py 的 start_scheduler() 中添加
|
||||
from app.services.learning_service import LearningService
|
||||
|
||||
async def daily_learning_job():
|
||||
"""每日凌晨0:30生成学习报告"""
|
||||
from app.database import get_db_session
|
||||
|
||||
async for db in get_db_session():
|
||||
service = LearningService(db)
|
||||
users = await get_all_active_users(db)
|
||||
for user in users:
|
||||
await service.generate_and_record_daily_learning(user.id)
|
||||
break
|
||||
|
||||
# 在 start_scheduler() 中注册
|
||||
scheduler.add_job(daily_learning_job, "cron", hour=0, minute=30, id="daily_learning")
|
||||
```
|
||||
|
||||
## 9. 错误处理
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 无活动数据 | 显示"今日暂无学习成果",不生成空记录 |
|
||||
| 知识图谱更新失败 | 回滚学习记录,标记为失败状态 |
|
||||
| AI生成失败 | 记录原始数据,标记需要重试 |
|
||||
| 用户发起学习主题为空 | 前端验证拦截,不发送请求 |
|
||||
|
||||
## 10. 访问控制
|
||||
|
||||
所有板块需要用户登录后访问:
|
||||
- 未登录用户显示"请先登录"提示
|
||||
- 不发送无效API请求
|
||||
- 保持页面结构完整
|
||||
|
||||
## 11. 技术实现
|
||||
|
||||
**前端:**
|
||||
- Vue 3 + TypeScript
|
||||
- 复用现有组件样式(StatsView.vue模式)
|
||||
- CSS实现迷你图表
|
||||
- lucide-vue-next图标
|
||||
|
||||
**后端:**
|
||||
- FastAPI + SQLAlchemy
|
||||
- 复用现有数据库连接
|
||||
- 新增三个Service类
|
||||
- 复用现有认证机制
|
||||
|
||||
**数据流:**
|
||||
```
|
||||
用户活动 → LearningService分析 → KGNode创建 → LearningRecord存储
|
||||
↓
|
||||
AI生成摘要 → 前端展示
|
||||
```
|
||||
307
docs/superpowers/specs/2026-03-21-knowledge-folder-design.md
Normal file
307
docs/superpowers/specs/2026-03-21-knowledge-folder-design.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# 知识库文件夹分层设计
|
||||
|
||||
> **Goal:** 为知识库添加文件夹分层组织功能,支持多层嵌套、CRUD 操作,支持知识大脑汇聚各类内容。
|
||||
|
||||
## 1. 概念与愿景
|
||||
|
||||
知识库是用户的**资料中枢**,文件夹分层让知识更有序。用户可以按主题/项目/类型建立文件夹层级,如 `技术文档/Python/入门.pdf`。
|
||||
|
||||
知识大脑会汇聚来自知识库、待办、看板、论坛、对话的内容,形成完整的用户知识画像。文件夹是知识的入口分类,而非知识图谱的一部分。
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 Folder 表(邻接表模式)
|
||||
|
||||
```python
|
||||
class Folder(BaseModel):
|
||||
__tablename__ = "folders"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
parent_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # NULL=根目录
|
||||
# 注意: id, created_at, updated_at 继承自 BaseModel
|
||||
```
|
||||
|
||||
**特点:**
|
||||
- 邻接表模式:通过 `parent_id` 指向父文件夹
|
||||
- 根目录文件夹的 `parent_id = NULL`
|
||||
- 查询完整树结构使用递归 CTE
|
||||
- **唯一约束**:`user_id + parent_id + name` 组合唯一,防止同级重名
|
||||
|
||||
**验证规则:**
|
||||
- 文件夹名称不能为空,最大 255 字符
|
||||
- 不允许包含字符:`/ \ * ? :`
|
||||
- 最大嵌套深度:10 层(防止 UI/性能问题)
|
||||
|
||||
### 2.2 Document 表变更
|
||||
|
||||
```python
|
||||
class Document(BaseModel):
|
||||
# ...现有字段...
|
||||
folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # 新增
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- `folder_id = NULL` 表示文档在根目录(未分类)
|
||||
- 删除文件夹时,级联删除该文件夹及其所有子文件夹中的文档
|
||||
|
||||
### 2.3 ChromaDB Metadata
|
||||
|
||||
```python
|
||||
{
|
||||
"document_id": "xxx",
|
||||
"document_title": "入门.pdf",
|
||||
"folder_path": "/技术文档/Python", # 完整路径,用于检索过滤
|
||||
"file_type": "pdf",
|
||||
"chunk_index": 0,
|
||||
}
|
||||
```
|
||||
|
||||
## 3. API 接口
|
||||
|
||||
### 3.1 文件夹管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/folders` | 获取用户的完整文件夹树 |
|
||||
| POST | `/api/folders` | 创建文件夹 `{ name, parent_id? }` |
|
||||
| PUT | `/api/folders/{id}` | 重命名文件夹 `{ name }` |
|
||||
| DELETE | `/api/folders/{id}` | 删除文件夹(级联删除文档) |
|
||||
|
||||
**GET /api/folders 响应:**
|
||||
```json
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"id": "xxx",
|
||||
"name": "技术文档",
|
||||
"parent_id": null,
|
||||
"children": [
|
||||
{
|
||||
"id": "yyy",
|
||||
"name": "Python",
|
||||
"parent_id": "xxx",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 文档管理变更
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/documents?folder_id=` | 按文件夹查询文档 |
|
||||
| POST | `/api/documents` | 上传文档时指定 `folder_id` |
|
||||
| DELETE | `/api/documents/{id}` | 删除文档 |
|
||||
|
||||
**POST /api/documents 请求增加可选字段:**
|
||||
```json
|
||||
{
|
||||
"file": "<binary>",
|
||||
"folder_id": "yyy" // 可选,不传表示根目录
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 安全与权限
|
||||
|
||||
**所有权验证:**
|
||||
- 所有文件夹操作必须验证 `folder.user_id == current_user.id`
|
||||
- 文档操作时验证 `document.user_id == current_user.id`
|
||||
- `folder_id` 参数需要验证归属,防止跨用户访问
|
||||
|
||||
**示例中间件:**
|
||||
```python
|
||||
async def verify_folder_access(folder_id: str, user_id: str, db: AsyncSession):
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == user_id)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权访问此文件夹")
|
||||
```
|
||||
|
||||
### 3.4 向量检索变更
|
||||
|
||||
`KnowledgeService.retrieve()` 增加可选参数 `folder_id`:
|
||||
|
||||
```python
|
||||
async def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
folder_id: str | None = None, # 新增
|
||||
top_k: int = 5,
|
||||
):
|
||||
# 如果指定 folder_id,构建 path 前缀过滤
|
||||
folder_path = await self._get_folder_path(folder_id)
|
||||
where = {"folder_path": {"$starts_with": folder_path}} if folder_path else None
|
||||
```
|
||||
|
||||
### 3.5 ChromaDB 同步策略
|
||||
|
||||
**文件夹重命名/移动时的同步:**
|
||||
|
||||
由于 ChromaDB metadata 中存储了 `folder_path`,当文件夹路径变化时需要同步更新:
|
||||
|
||||
```python
|
||||
async def update_folder_paths(folder_id: str, old_path: str, new_path: str):
|
||||
"""更新所有子文件夹和文档的路径"""
|
||||
# 1. 更新所有子文件夹的 path
|
||||
children = await db.execute(
|
||||
select(Folder).where(Folder.parent_id == folder_id)
|
||||
)
|
||||
for child in children.scalars():
|
||||
child_new_path = new_path + "/" + child.name
|
||||
await update_folder_paths(child.id, old_path + "/" + child.name, child_new_path)
|
||||
|
||||
# 2. 更新该文件夹下所有文档的 ChromaDB metadata
|
||||
docs = await db.execute(
|
||||
select(Document).where(Document.folder_id == folder_id)
|
||||
)
|
||||
for doc in docs.scalars():
|
||||
collection.update(
|
||||
where={"document_id": doc.id},
|
||||
set={"folder_path": new_path}
|
||||
)
|
||||
```
|
||||
|
||||
**删除文件夹时的清理:**
|
||||
|
||||
```python
|
||||
async def delete_folder_cascade(folder_id: str):
|
||||
"""级联删除:先删子文件夹,再删文档,最后删自己"""
|
||||
# 1. 递归删除所有子文件夹
|
||||
children = await db.execute(
|
||||
select(Folder).where(Folder.parent_id == folder_id)
|
||||
)
|
||||
for child in children.scalars():
|
||||
await delete_folder_cascade(child.id)
|
||||
|
||||
# 2. 删除该文件夹下所有文档(从 ChromaDB 和数据库)
|
||||
docs = await db.execute(
|
||||
select(Document).where(Document.folder_id == folder_id)
|
||||
)
|
||||
for doc in docs.scalars():
|
||||
await knowledge_service.delete_from_vectorstore(user_id, doc.id)
|
||||
await db.delete(doc)
|
||||
|
||||
# 3. 删除文件夹本身
|
||||
folder = await db.get(Folder, folder_id)
|
||||
await db.delete(folder)
|
||||
```
|
||||
|
||||
## 4. 前端设计
|
||||
|
||||
### 4.1 布局结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ KNOWLEDGE BASE [+新建文件夹] [+上传] │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📁 技术文档 │ 搜索栏 [🔍 搜索...] [混合▼] │
|
||||
│ 📁 Python │ │
|
||||
│ 📄 入门 │ ┌─────────────────────────────────┐ │
|
||||
│ 📄 进阶 │ │ 文档标题 │ │
|
||||
│ 📁 Vue │ │ 类型 · 大小 · 状态 │ │
|
||||
│ 📁 产品 │ └─────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ 📁 临时文件 │ ┌─────────────────────────────────┐ │
|
||||
│ │ │ ... │ │
|
||||
│ │ └─────────────────────────────────┘ │
|
||||
└──────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 组件结构
|
||||
|
||||
```
|
||||
KnowledgeView
|
||||
├── Header (标题 + 操作按钮)
|
||||
├── MainLayout (flexbox: sidebar + content)
|
||||
│ ├── FolderTree (左侧边栏)
|
||||
│ │ ├── FolderItem (递归组件)
|
||||
│ │ │ ├── folder icon + name
|
||||
│ │ │ ├── children (递归)
|
||||
│ │ │ └── context menu (右键: 重命名/删除)
|
||||
│ │ └── AddFolderButton
|
||||
│ │
|
||||
│ └── ContentArea (右侧主区域)
|
||||
│ ├── SearchBar
|
||||
│ ├── UploadZone
|
||||
│ ├── DocumentList
|
||||
│ └── SearchResults
|
||||
```
|
||||
|
||||
### 4.3 交互细节
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 点击文件夹 | 高亮选中,显示该文件夹下文档 |
|
||||
| 右键文件夹 | 弹出菜单:重命名 / 删除 |
|
||||
| 双击文件夹名 | 进入编辑状态 |
|
||||
| 新建文件夹 | 弹出输入框,默认在当前选中位置创建 |
|
||||
| 上传文档 | 需先选择目标文件夹,否则默认根目录 |
|
||||
| 搜索 | 可选限定在当前文件夹内搜索 |
|
||||
|
||||
### 4.4 UI 风格
|
||||
|
||||
保持一致的 sci-fi holographic 风格:
|
||||
- 主色调:青色 `#00f5d4` + 深色背景
|
||||
- 文件夹图标:使用 Folder/FolderOpen 图标
|
||||
- 悬停/选中状态:边框高亮 + 背景色变化
|
||||
- 动画:展开/折叠动画 200ms ease
|
||||
|
||||
## 5. 实施步骤
|
||||
|
||||
### Phase 1: 数据层
|
||||
1. 创建 `Folder` 模型和表
|
||||
2. 修改 `Document` 模型,增加 `folder_id` 外键
|
||||
3. 添加数据库迁移
|
||||
|
||||
### Phase 2: 后端 API
|
||||
1. 实现文件夹 CRUD 接口
|
||||
2. 修改文档上传接口,支持 `folder_id`
|
||||
3. 修改文档列表接口,支持 `folder_id` 过滤
|
||||
4. 修改向量检索,支持 `folder_id` 范围限定
|
||||
5. 实现递归 CTE 查询文件夹树
|
||||
|
||||
**递归 CTE 示例(获取完整文件夹树):**
|
||||
```sql
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
-- 基础查询:根文件夹
|
||||
SELECT id, name, parent_id, 0 as depth
|
||||
FROM folders
|
||||
WHERE user_id = :user_id AND parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 递归查询:子文件夹
|
||||
SELECT f.id, f.name, f.parent_id, ft.depth + 1
|
||||
FROM folders f
|
||||
INNER JOIN folder_tree ft ON ft.id = f.parent_id
|
||||
WHERE f.user_id = :user_id
|
||||
)
|
||||
SELECT * FROM folder_tree ORDER BY depth, name;
|
||||
```
|
||||
|
||||
### Phase 3: 前端
|
||||
1. 创建 `FolderTree` 组件
|
||||
2. 改造 `KnowledgeView` 布局
|
||||
3. 实现文件夹右键菜单(重命名/删除)
|
||||
4. 实现新建文件夹弹窗
|
||||
5. 上传时强制选择文件夹
|
||||
|
||||
### Phase 4: 测试
|
||||
1. 文件夹 CRUD 测试
|
||||
2. 级联删除测试(删除文件夹 + 文档)
|
||||
3. 向量检索按文件夹过滤测试
|
||||
4. 前端交互测试
|
||||
|
||||
## 6. 技术约束
|
||||
|
||||
- SQLite 的递归 CTE 查询文件夹树
|
||||
- 删除文件夹时先删除子文件夹(递归),再删除文档
|
||||
- ChromaDB 的 `where` 过滤使用 `$starts_with` 做路径前缀匹配
|
||||
- 前端递归组件注意防止无限循环
|
||||
Reference in New Issue
Block a user