feat(agents): Phase 8.4-10.5 built-in plugins, bundled skills, coordinator
This commit is contained in:
@@ -1,347 +0,0 @@
|
||||
# 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`
|
||||
|
||||
是否现在开始?
|
||||
@@ -1,711 +0,0 @@
|
||||
# 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** - 在当前会话中按批次执行任务
|
||||
|
||||
选择哪种方式?
|
||||
@@ -1,44 +0,0 @@
|
||||
# Daily Todo 数据库迁移
|
||||
|
||||
## 自动迁移
|
||||
|
||||
SQLAlchemy 会在应用启动时通过 `init_db()` 自动创建所有表,包括 `daily_todos` 表。
|
||||
|
||||
## 手动迁移(如需)
|
||||
|
||||
如果需要手动创建表,执行以下 SQL:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS daily_todos (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
is_completed BOOLEAN NOT NULL DEFAULT 0,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
source_detail VARCHAR(500),
|
||||
source_ref_id VARCHAR(36),
|
||||
todo_date VARCHAR(10) NOT NULL,
|
||||
completed_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_date ON daily_todos(user_id, todo_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_id ON daily_todos(user_id);
|
||||
```
|
||||
|
||||
## 表说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(36) | 主键,UUID |
|
||||
| user_id | VARCHAR(36) | 所属用户,索引 |
|
||||
| title | VARCHAR(500) | 待办标题 |
|
||||
| is_completed | BOOLEAN | 是否完成,默认 false |
|
||||
| source | VARCHAR(20) | 来源:ai_kanban / ai_chat / manual |
|
||||
| source_detail | VARCHAR(500) | 来源说明文本 |
|
||||
| source_ref_id | VARCHAR(36) | 来源原始ID(看板TaskID或对话ID) |
|
||||
| todo_date | VARCHAR(10) | 所属日期 YYYY-MM-DD |
|
||||
| completed_at | TIMESTAMP | 完成时间 |
|
||||
| created_at | TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 更新时间 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,555 +0,0 @@
|
||||
# Jarvis Knowledge Brain Phase 1 Task Breakdown
|
||||
|
||||
## Goal
|
||||
Turn the phase-1 knowledge brain blueprint into an execution-ready development task list tied to the current codebase.
|
||||
|
||||
---
|
||||
|
||||
## A. Backend Persistence Tasks
|
||||
|
||||
### A1. Add new brain models
|
||||
Create new SQLAlchemy models under `backend/app/models/`:
|
||||
- `brain_event.py`
|
||||
- `brain_candidate.py`
|
||||
- `brain_memory.py`
|
||||
- `brain_tag.py`
|
||||
- optional link-table definitions in `brain_relations.py` or colocated within the above files
|
||||
|
||||
Core entities to add:
|
||||
- `BrainEvent`
|
||||
- `BrainCandidate`
|
||||
- `BrainMemory`
|
||||
- `BrainTag`
|
||||
- `BrainEventTag`
|
||||
- `BrainCandidateTag`
|
||||
- `BrainMemoryTag`
|
||||
- optional `BrainMemoryEvent`
|
||||
|
||||
Acceptance criteria:
|
||||
- All models inherit from the project base model pattern.
|
||||
- All required enums/status fields are defined.
|
||||
- User ownership and timeline fields exist.
|
||||
- Link tables support tag filtering and source traceability.
|
||||
|
||||
### A2. Register models in model exports
|
||||
Update:
|
||||
- `backend/app/models/__init__.py`
|
||||
|
||||
Acceptance criteria:
|
||||
- New brain models are imported and available during metadata initialization.
|
||||
|
||||
### A3. Add migration / schema evolution support
|
||||
Depending on current project migration approach, add the required DB migration path for the new tables.
|
||||
|
||||
Acceptance criteria:
|
||||
- New tables can be created in local/dev environments without breaking existing tables.
|
||||
- Indexes for `user_id`, status, and date-based access patterns are included.
|
||||
|
||||
### A4. Add Pydantic schemas
|
||||
Create new schema files under `backend/app/schemas/`:
|
||||
- `brain.py`
|
||||
|
||||
Schema groups to add:
|
||||
- overview response
|
||||
- memory list/detail response
|
||||
- candidate list response
|
||||
- tag response
|
||||
- timeline response
|
||||
- manual learning trigger response
|
||||
- memory/tag management payloads
|
||||
|
||||
Acceptance criteria:
|
||||
- Schemas match the intended `/api/brain` response shapes.
|
||||
- Timeline and traceability structures are explicit, not loosely typed blobs.
|
||||
|
||||
---
|
||||
|
||||
## B. Backend Service Tasks
|
||||
|
||||
### B1. Create brain event ingestion service
|
||||
Add:
|
||||
- `backend/app/services/brain_event_service.py`
|
||||
|
||||
Responsibilities:
|
||||
- normalize source records into `BrainEvent`
|
||||
- expose helpers such as:
|
||||
- `record_conversation_event(...)`
|
||||
- `record_document_event(...)`
|
||||
- `record_todo_event(...)`
|
||||
- `record_task_event(...)`
|
||||
- `record_forum_event(...)`
|
||||
|
||||
Acceptance criteria:
|
||||
- Each helper accepts current source-domain inputs without forcing those modules to understand brain internals.
|
||||
- Event creation is idempotent enough to avoid obvious duplicate rows for the same source update.
|
||||
|
||||
### B2. Create brain learning service
|
||||
Add:
|
||||
- `backend/app/services/brain_learning_service.py`
|
||||
|
||||
Responsibilities:
|
||||
- load pending `BrainEvent`s for a given date/user scope
|
||||
- cluster related events
|
||||
- call the LLM to create candidate knowledge
|
||||
- score and dedupe candidates
|
||||
- promote high-confidence candidates into `BrainMemory`
|
||||
- mark processed events and candidate statuses
|
||||
|
||||
Acceptance criteria:
|
||||
- Service supports both manual run and scheduler run.
|
||||
- Promotion/rejection decisions are explicit and testable.
|
||||
- Source event traceability is preserved.
|
||||
|
||||
### B3. Create brain tag service
|
||||
Add:
|
||||
- `backend/app/services/brain_tag_service.py`
|
||||
|
||||
Responsibilities:
|
||||
- attach and score tags
|
||||
- split tags into important vs secondary
|
||||
- update tag scores after learning runs
|
||||
- support cleanup recommendations
|
||||
|
||||
Acceptance criteria:
|
||||
- Important/secondary classification is persisted, not only computed in the UI.
|
||||
- Tag lookups support filtering memories and timeline entries.
|
||||
|
||||
### B4. Create brain retrieval service
|
||||
Add:
|
||||
- `backend/app/services/brain_retrieval_service.py`
|
||||
|
||||
Responsibilities:
|
||||
- retrieve relevant `BrainMemory` records by query
|
||||
- optionally retrieve recent events for recency-sensitive prompts
|
||||
- format results for chat injection and API responses
|
||||
|
||||
Acceptance criteria:
|
||||
- Retrieval has strict limits to prevent prompt bloat.
|
||||
- Results support filtering by tags, source type, and time range.
|
||||
|
||||
### B5. Refactor or extend memory service
|
||||
Update:
|
||||
- `backend/app/services/memory_service.py`
|
||||
|
||||
Tasks:
|
||||
- keep existing summary and `UserMemory` behavior intact
|
||||
- extend `build_memory_context()` to append a `【知识大脑】` block from `BrainRetrievalService`
|
||||
- keep memory context size bounded
|
||||
|
||||
Acceptance criteria:
|
||||
- Existing conversation summary behavior continues to work.
|
||||
- Chat can consume `BrainMemory` without requiring a full prompt architecture rewrite.
|
||||
|
||||
---
|
||||
|
||||
## C. Source Ingestion Integration Tasks
|
||||
|
||||
### C1. Conversation → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/services/agent_service.py`
|
||||
- possibly `backend/app/services/memory_service.py`
|
||||
|
||||
Hook points:
|
||||
- after user message persistence
|
||||
- after assistant response persistence
|
||||
- after summary/memory extraction
|
||||
|
||||
Acceptance criteria:
|
||||
- Important conversation actions produce normalized `BrainEvent`s.
|
||||
- Explicit “remember this” signals are captured as stronger events.
|
||||
|
||||
### C2. Document → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/routers/document.py`
|
||||
- `backend/app/services/document_service.py`
|
||||
- `backend/app/services/knowledge_service.py`
|
||||
|
||||
Hook points:
|
||||
- upload success
|
||||
- indexing completion
|
||||
- chunk edit / reindex
|
||||
|
||||
Acceptance criteria:
|
||||
- Document lifecycle milestones become `BrainEvent`s.
|
||||
- Source metadata includes document identity and folder context.
|
||||
|
||||
### C3. Todo → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/routers/todo.py`
|
||||
- `backend/app/services/todo_service.py`
|
||||
|
||||
Hook points:
|
||||
- todo creation
|
||||
- completion
|
||||
- AI-generated todo creation
|
||||
|
||||
Acceptance criteria:
|
||||
- Todo events reflect both planning and completion signals.
|
||||
- AI-generated todos are distinguishable from manual ones.
|
||||
|
||||
### C4. Task/Kanban → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/routers/task.py`
|
||||
|
||||
Hook points:
|
||||
- task creation
|
||||
- status change
|
||||
- completion
|
||||
- priority change
|
||||
|
||||
Acceptance criteria:
|
||||
- Task state changes create meaningful workstream events.
|
||||
- Duplicate writes are avoided on no-op updates.
|
||||
|
||||
### C5. Forum → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/routers/forum.py`
|
||||
- optionally `backend/app/services/scheduler_service.py`
|
||||
|
||||
Hook points:
|
||||
- post created
|
||||
- reply created
|
||||
- forum instruction execution
|
||||
|
||||
Acceptance criteria:
|
||||
- Forum posts/replies that matter to project state become brain events.
|
||||
- Source traceability includes whether the event came from a post, reply, or executed instruction.
|
||||
|
||||
---
|
||||
|
||||
## D. Scheduler and Daily Learning Tasks
|
||||
|
||||
### D1. Add daily brain learning job
|
||||
Update:
|
||||
- `backend/app/services/scheduler_service.py`
|
||||
|
||||
Add:
|
||||
- `brain_daily_learning_task()`
|
||||
|
||||
Responsibilities:
|
||||
- run daily for pending events
|
||||
- invoke `BrainLearningService`
|
||||
- log promoted/rejected counts
|
||||
|
||||
Acceptance criteria:
|
||||
- Job is registered in `start_scheduler()`.
|
||||
- Job can run safely when there are no pending events.
|
||||
|
||||
### D2. Add manual trigger path
|
||||
Update or add:
|
||||
- `backend/app/routers/scheduler.py` or the new `backend/app/routers/brain.py`
|
||||
|
||||
Acceptance criteria:
|
||||
- Developers/users can manually run learning for testing.
|
||||
- Trigger returns a useful summary, not only a started flag.
|
||||
|
||||
### D3. Decide scheduler ownership model for phase 1
|
||||
Current scheduler is global. Decide whether phase 1 runs:
|
||||
- for all users in one job, or
|
||||
- per user loop inside one job
|
||||
|
||||
Acceptance criteria:
|
||||
- No hard-coded `user_id="default"` behavior remains in new brain learning flow.
|
||||
- User iteration strategy is explicit.
|
||||
|
||||
---
|
||||
|
||||
## E. Backend API Tasks
|
||||
|
||||
### E1. Add brain router
|
||||
Create:
|
||||
- `backend/app/routers/brain.py`
|
||||
|
||||
Register in:
|
||||
- `backend/app/main.py`
|
||||
- `backend/app/routers/__init__.py` if needed
|
||||
|
||||
### E2. Implement overview endpoint
|
||||
Endpoint:
|
||||
- `GET /api/brain/overview`
|
||||
|
||||
Should return:
|
||||
- active memory count
|
||||
- candidate count
|
||||
- important tag count
|
||||
- recent event count
|
||||
- last learning run info
|
||||
- today’s promoted/rejected summary
|
||||
|
||||
### E3. Implement memory endpoints
|
||||
Endpoints:
|
||||
- `GET /api/brain/memories`
|
||||
- `GET /api/brain/memory/{id}`
|
||||
- `POST /api/brain/memory/{id}/archive`
|
||||
- `DELETE /api/brain/memory/{id}`
|
||||
- optional `POST /api/brain/memory/{id}/promote` if candidate-to-memory management is exposed here
|
||||
|
||||
Acceptance criteria:
|
||||
- Memory detail shows source traceability and tags.
|
||||
- List endpoint supports pagination/filters needed by UI.
|
||||
|
||||
### E4. Implement candidate endpoints
|
||||
Endpoints:
|
||||
- `GET /api/brain/candidates`
|
||||
- optional promote/reject endpoints if candidates are user-manageable in phase 1
|
||||
|
||||
Acceptance criteria:
|
||||
- Candidate status and scoring are inspectable.
|
||||
|
||||
### E5. Implement tag endpoints
|
||||
Endpoints:
|
||||
- `GET /api/brain/tags`
|
||||
- `POST /api/brain/tag/{id}/promote`
|
||||
- `POST /api/brain/tag/{id}/demote`
|
||||
- `DELETE /api/brain/tag/{id}`
|
||||
|
||||
Acceptance criteria:
|
||||
- API groups tags by important vs secondary.
|
||||
- Manual cleanup actions are supported.
|
||||
|
||||
### E6. Implement timeline endpoint
|
||||
Endpoint:
|
||||
- `GET /api/brain/timeline`
|
||||
|
||||
Acceptance criteria:
|
||||
- Timeline groups records by day or returns a structure easily grouped by day in UI.
|
||||
- Includes event entries and memory promotion entries.
|
||||
|
||||
### E7. Implement learning trigger endpoint
|
||||
Endpoint:
|
||||
- `POST /api/brain/learn/run`
|
||||
|
||||
Acceptance criteria:
|
||||
- Supports manual learning run for current user or all users, depending on phase-1 policy.
|
||||
- Returns meaningful run stats.
|
||||
|
||||
---
|
||||
|
||||
## F. Chat Integration Tasks
|
||||
|
||||
### F1. Inject knowledge brain into chat context
|
||||
Update:
|
||||
- `backend/app/services/agent_service.py`
|
||||
- `backend/app/services/memory_service.py`
|
||||
|
||||
Acceptance criteria:
|
||||
- Relevant `BrainMemory` items appear in prompt context.
|
||||
- Context remains concise and bounded.
|
||||
- Existing response flow remains stable.
|
||||
|
||||
### F2. Add retrieval policy guardrails
|
||||
Tasks:
|
||||
- define per-query memory limits
|
||||
- choose when to include recent events
|
||||
- prefer important/high-confidence memories
|
||||
|
||||
Acceptance criteria:
|
||||
- Brain retrieval does not overwhelm standard conversation context.
|
||||
- Time-sensitive answers can still include recent context when needed.
|
||||
|
||||
---
|
||||
|
||||
## G. Frontend Route and Navigation Tasks
|
||||
|
||||
### G1. Introduce a real brain route
|
||||
Update likely files:
|
||||
- `frontend/src/app/router/routes.ts`
|
||||
- `frontend/src/app/navigation/nav.ts`
|
||||
|
||||
Tasks:
|
||||
- add `/brain`
|
||||
- make `知识大脑` point to `/brain`
|
||||
- keep `/graph` available as a subview or secondary route
|
||||
|
||||
Acceptance criteria:
|
||||
- Brain is no longer represented only by the graph page.
|
||||
|
||||
### G2. Define frontend brain API client
|
||||
Add:
|
||||
- `frontend/src/api/brain.ts`
|
||||
|
||||
Methods:
|
||||
- `getOverview`
|
||||
- `getMemories`
|
||||
- `getMemoryDetail`
|
||||
- `getCandidates`
|
||||
- `getTags`
|
||||
- `getTimeline`
|
||||
- `runLearning`
|
||||
- memory/tag management actions
|
||||
|
||||
Acceptance criteria:
|
||||
- API client matches backend router contract.
|
||||
|
||||
---
|
||||
|
||||
## H. Frontend Brain Dashboard Tasks
|
||||
|
||||
### H1. Create new brain page
|
||||
Add:
|
||||
- `frontend/src/pages/brain/index.vue`
|
||||
|
||||
Core page sections:
|
||||
- overview header
|
||||
- important tags panel
|
||||
- secondary tags panel
|
||||
- recent learned knowledge section
|
||||
- timeline section
|
||||
- graph tab/subview entry
|
||||
|
||||
Acceptance criteria:
|
||||
- Page is useful even before graph projection is upgraded.
|
||||
- Dashboard reflects the brain, not just visualized relationships.
|
||||
|
||||
### H2. Add page composable/state logic
|
||||
Add:
|
||||
- `frontend/src/pages/brain/composables/useBrainView.ts`
|
||||
|
||||
Responsibilities:
|
||||
- fetch overview/tags/memories/timeline
|
||||
- manage filters and selected tags
|
||||
- trigger manual learning run
|
||||
- manage loading/error states
|
||||
|
||||
Acceptance criteria:
|
||||
- Page logic stays separate from template complexity.
|
||||
|
||||
### H3. Add memory list/detail components
|
||||
Suggested additions:
|
||||
- `frontend/src/components/brain/BrainMemoryList.vue`
|
||||
- `frontend/src/components/brain/BrainMemoryDetail.vue`
|
||||
- `frontend/src/components/brain/BrainTagPanel.vue`
|
||||
- `frontend/src/components/brain/BrainTimeline.vue`
|
||||
|
||||
Acceptance criteria:
|
||||
- User can inspect why a memory exists.
|
||||
- User can archive/delete memories and promote/demote tags.
|
||||
|
||||
### H4. Reposition graph as brain subview
|
||||
Possible approaches:
|
||||
- keep current `frontend/src/pages/graph/index.vue` but link it from `/brain`
|
||||
- or wrap the graph page as one tab inside the brain page
|
||||
|
||||
Acceptance criteria:
|
||||
- Existing graph functionality remains accessible.
|
||||
- Product framing changes from “brain = graph” to “brain includes graph”.
|
||||
|
||||
---
|
||||
|
||||
## I. Testing Tasks
|
||||
|
||||
### I1. Backend model/service tests
|
||||
Add tests for:
|
||||
- event creation
|
||||
- candidate generation status changes
|
||||
- promotion into `BrainMemory`
|
||||
- tag priority updates
|
||||
- timeline aggregation
|
||||
|
||||
Suggested locations:
|
||||
- `backend/tests/backend/app/services/`
|
||||
- `backend/tests/backend/app/routers/`
|
||||
|
||||
### I2. Retrieval integration tests
|
||||
Add tests for:
|
||||
- memory context injection
|
||||
- retrieval limits
|
||||
- recency-sensitive event inclusion
|
||||
|
||||
### I3. API tests
|
||||
Add tests for:
|
||||
- `/api/brain/overview`
|
||||
- `/api/brain/memories`
|
||||
- `/api/brain/tags`
|
||||
- `/api/brain/timeline`
|
||||
- `/api/brain/learn/run`
|
||||
|
||||
### I4. Frontend tests
|
||||
Add tests for:
|
||||
- brain composable fetch flow
|
||||
- filter behavior
|
||||
- manual learning run UI flow
|
||||
- tag grouping and memory rendering
|
||||
|
||||
---
|
||||
|
||||
## J. Recommended Execution Order
|
||||
|
||||
### Wave 1: Foundation
|
||||
1. A1-A4 persistence and schemas
|
||||
2. B1 brain event service
|
||||
3. E1 add router skeleton
|
||||
|
||||
### Wave 2: Ingestion
|
||||
4. C1-C5 connect all source domains to `BrainEvent`
|
||||
|
||||
### Wave 3: Learning
|
||||
5. B2 brain learning service
|
||||
6. B3 brain tag service
|
||||
7. D1-D3 scheduler/manual learning
|
||||
|
||||
### Wave 4: Retrieval
|
||||
8. B4 brain retrieval service
|
||||
9. B5 memory service integration
|
||||
10. F1-F2 chat injection and guardrails
|
||||
|
||||
### Wave 5: Product surface
|
||||
11. E2-E7 complete `/api/brain` endpoints
|
||||
12. G1-G2 routing + API client
|
||||
13. H1-H4 dashboard and graph repositioning
|
||||
|
||||
### Wave 6: Reliability
|
||||
14. I1-I4 tests and refinement
|
||||
|
||||
---
|
||||
|
||||
## K. Files Most Likely to Change in Phase 1
|
||||
|
||||
### Backend new files
|
||||
- `backend/app/models/brain_event.py`
|
||||
- `backend/app/models/brain_candidate.py`
|
||||
- `backend/app/models/brain_memory.py`
|
||||
- `backend/app/models/brain_tag.py`
|
||||
- `backend/app/schemas/brain.py`
|
||||
- `backend/app/services/brain_event_service.py`
|
||||
- `backend/app/services/brain_learning_service.py`
|
||||
- `backend/app/services/brain_tag_service.py`
|
||||
- `backend/app/services/brain_retrieval_service.py`
|
||||
- `backend/app/routers/brain.py`
|
||||
|
||||
### Backend existing files
|
||||
- `backend/app/models/__init__.py`
|
||||
- `backend/app/main.py`
|
||||
- `backend/app/services/memory_service.py`
|
||||
- `backend/app/services/agent_service.py`
|
||||
- `backend/app/services/scheduler_service.py`
|
||||
- `backend/app/routers/document.py`
|
||||
- `backend/app/routers/todo.py`
|
||||
- `backend/app/routers/task.py`
|
||||
- `backend/app/routers/forum.py`
|
||||
- possibly `backend/app/services/document_service.py`
|
||||
- possibly `backend/app/services/knowledge_service.py`
|
||||
|
||||
### Frontend new files
|
||||
- `frontend/src/api/brain.ts`
|
||||
- `frontend/src/pages/brain/index.vue`
|
||||
- `frontend/src/pages/brain/composables/useBrainView.ts`
|
||||
- brain-related components under `frontend/src/components/brain/`
|
||||
|
||||
### Frontend existing files
|
||||
- `frontend/src/app/router/routes.ts`
|
||||
- `frontend/src/app/navigation/nav.ts`
|
||||
- optionally `frontend/src/pages/graph/index.vue`
|
||||
|
||||
---
|
||||
|
||||
## L. Phase 1 “Definition of Done” Checklist
|
||||
- [ ] Brain persistence models exist and are queryable.
|
||||
- [ ] All five core domains emit `BrainEvent`s.
|
||||
- [ ] Daily learning creates `BrainCandidate`s and promotes durable `BrainMemory`s.
|
||||
- [ ] Tag priority is stored and manageable.
|
||||
- [ ] Chat can retrieve relevant brain knowledge.
|
||||
- [ ] `/api/brain` endpoints support dashboard and management actions.
|
||||
- [ ] `/brain` dashboard exists and is usable without relying on the graph page.
|
||||
- [ ] Graph remains available as a secondary/projection view.
|
||||
- [ ] Automated tests cover ingestion, promotion, retrieval, and UI basics.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Task Plan: Jarvis Knowledge Brain Phase 1 Blueprint
|
||||
|
||||
## Goal
|
||||
Create a practical phase-1 implementation blueprint for the event-driven knowledge brain, covering backend models, services, scheduler jobs, retrieval integration, APIs, and frontend brain module structure.
|
||||
|
||||
## Phases
|
||||
- [x] Phase 1: Plan and setup
|
||||
- [x] Phase 2: Research/gather information
|
||||
- [x] Phase 3: Draft blueprint
|
||||
- [x] Phase 4: Review and deliver
|
||||
|
||||
## Key Questions
|
||||
1. Which new persistence models are required for an event-driven knowledge brain?
|
||||
2. How should existing conversation, document, todo, task, and forum data flow into the brain?
|
||||
3. What should phase 1 include versus defer to later phases?
|
||||
4. How should the frontend brain module be structured before full graph intelligence exists?
|
||||
|
||||
## Decisions Made
|
||||
- Use an event-driven brain architecture instead of extending the current graph-only flow.
|
||||
- Keep the current graph as a projection/view layer, not the brain source of truth.
|
||||
- Phase 1 should prioritize unified ingestion, candidate generation, long-term memory storage, and retrieval integration.
|
||||
|
||||
## Errors Encountered
|
||||
- None yet.
|
||||
|
||||
## Status
|
||||
**Completed** - Separate implementation plan drafted in `knowledge_ingestion_plan.md` and supporting notes updated.
|
||||
@@ -1,210 +0,0 @@
|
||||
# Knowledge Ingestion Normalization Plan
|
||||
|
||||
## Goal
|
||||
Introduce a unified structured-markdown ingestion pipeline for the knowledge center: MinerU for PDF, existing parsers for DOCX/XLSX/CSV/MD/TXT, persisted normalized content, and lightweight hierarchical chunk semantics.
|
||||
|
||||
## Scope
|
||||
- Backend document parsing and normalization flow
|
||||
- Document persistence model updates
|
||||
- Incremental retrieval/indexing integration
|
||||
- Backfill/reindex strategy for existing documents
|
||||
- Test strategy for parser, router, and migration behavior
|
||||
|
||||
## Non-Goals
|
||||
- Full parent-child chunk graph tables in this phase
|
||||
- Rewriting all chunking logic to markdown-first immediately
|
||||
- Replacing all non-PDF parsers with a new framework
|
||||
- Solving every OCR/image-understanding case in the first pass
|
||||
|
||||
## Architecture Decisions
|
||||
- **PDF parser:** MinerU
|
||||
- **Other parsers:** keep current implementations for DOCX/XLSX/CSV/MD/TXT
|
||||
- **Canonical intermediate representation:** `ParsedDocument + structured_markdown`
|
||||
- **Canonical persisted content:** add `normalized_content` to `documents`
|
||||
- **Hierarchy model:** metadata-based lightweight semantics, not hard foreign-key parent-child chunk tables
|
||||
- **Migration strategy:** additive schema change + on-demand rebuild/reindex
|
||||
|
||||
## Target Flow
|
||||
1. Upload file
|
||||
2. Parse by type
|
||||
- PDF -> MinerU -> normalize to ParsedDocument
|
||||
- Other formats -> current parser -> ParsedDocument
|
||||
3. Render `ParsedDocument` into `structured_markdown`
|
||||
4. Persist document record including `normalized_content`
|
||||
5. Build chunks (initially still from nodes, enriched with lightweight hierarchy metadata)
|
||||
6. Index into vector store
|
||||
7. Serve preview from `normalized_content`
|
||||
|
||||
## Data Model Changes
|
||||
### documents table
|
||||
Add fields:
|
||||
- `normalized_content TEXT NULL`
|
||||
- `normalized_format VARCHAR(50) NULL` (value like `structured_markdown`)
|
||||
- optional later: `normalization_version VARCHAR(50) NULL`
|
||||
|
||||
### document_chunks metadata
|
||||
Enrich chunk metadata with lightweight hierarchy keys:
|
||||
- `chunk_level`
|
||||
- `parent_key`
|
||||
- `block_key`
|
||||
- existing structural metadata remains (`section_path`, `section_title`, `page_number`, `sheet_name`, `row_start`, `row_end`, `content_type`)
|
||||
|
||||
Rationale:
|
||||
- Supports grouped retrieval and contextual reconstruction
|
||||
- Avoids introducing a relational chunk tree prematurely
|
||||
|
||||
## Backend Implementation Steps
|
||||
### Phase 1: Schema and persistence
|
||||
Files:
|
||||
- `backend/app/models/document.py`
|
||||
- `backend/app/database.py`
|
||||
- `backend/app/schemas/document.py`
|
||||
- tests under `backend/tests/backend/app`
|
||||
|
||||
Changes:
|
||||
- Add `normalized_content` and `normalized_format` to `Document`
|
||||
- Extend `ensure_document_columns()` to backfill the new columns for existing databases
|
||||
- Expose `normalized_content` only where needed for preview/read APIs (avoid broad API expansion if not required yet)
|
||||
|
||||
### Phase 2: Introduce structured markdown renderer
|
||||
Files:
|
||||
- `backend/app/services/document_service.py`
|
||||
- possibly a new helper module if the renderer gets too large, but prefer keeping it local initially
|
||||
|
||||
Changes:
|
||||
- Add `_render_structured_markdown(parsed: ParsedDocument) -> str`
|
||||
- Keep current per-format parsing functions
|
||||
- After parsing, render once and store into `document.normalized_content`
|
||||
- Add `normalized_format='structured_markdown'`
|
||||
|
||||
Rendering guidance:
|
||||
- headings -> markdown headings
|
||||
- paragraphs/text -> plain markdown paragraphs
|
||||
- CSV/XLSX tables -> markdown table blocks or fenced structured table blocks when tables are too large/wide
|
||||
- PDF page boundaries -> explicit page markers
|
||||
- preserve contextual markers in metadata even if markdown cannot express everything perfectly
|
||||
|
||||
### Phase 3: MinerU integration for PDF
|
||||
Files:
|
||||
- `backend/app/services/document_service.py`
|
||||
- `backend/pyproject.toml` / lockfile if dependencies are added
|
||||
- config if MinerU requires configurable paths/options
|
||||
|
||||
Changes:
|
||||
- Replace PDF branch with MinerU-backed parsing
|
||||
- Map MinerU output into internal `ParsedNode`/`ParsedDocument`
|
||||
- Preserve page and block order
|
||||
- Represent image blocks as markdown placeholders plus metadata
|
||||
|
||||
Image policy:
|
||||
- First pass: extract image block references, page number, nearby text, and optional captions
|
||||
- Do not perform full image understanding for every image in phase 1
|
||||
- Design metadata so high-value image understanding can be added later
|
||||
|
||||
### Phase 4: Chunk metadata enrichment
|
||||
Files:
|
||||
- `backend/app/services/document_service.py`
|
||||
- `backend/app/services/knowledge_service.py`
|
||||
- tests
|
||||
|
||||
Changes:
|
||||
- Extend `_build_chunks()` to include lightweight hierarchy metadata:
|
||||
- section headings become natural parent keys
|
||||
- row batches / sheet blocks get stable block keys
|
||||
- PDF page/section blocks preserve ordered grouping
|
||||
- Keep current retrieval behavior, but let `_get_related_chunks()` benefit from richer metadata if helpful
|
||||
|
||||
### Phase 5: Preview and rebuild behavior
|
||||
Files:
|
||||
- `backend/app/routers/document.py`
|
||||
- `backend/app/services/document_service.py`
|
||||
|
||||
Changes:
|
||||
- `get_document_content()` should prefer `normalized_content`
|
||||
- Fallback to legacy file reading only when normalized content is absent
|
||||
- Rebuild/reindex paths should regenerate normalized content before chunk rebuild/indexing
|
||||
|
||||
### Phase 6: Backfill strategy
|
||||
Approach:
|
||||
- Add a rebuild endpoint or reuse existing reindex flow to backfill `normalized_content`
|
||||
- Existing documents can be migrated lazily:
|
||||
- when opened
|
||||
- when reindexed
|
||||
- or via an admin/batch rebuild command later
|
||||
|
||||
This avoids a risky one-shot migration.
|
||||
|
||||
## Error Handling Changes
|
||||
Current issue:
|
||||
- Upload route can leak parser/dependency problems as generic 500s.
|
||||
|
||||
Changes:
|
||||
- Convert expected parser/business errors to explicit 4xx responses where appropriate
|
||||
- For missing optional parser dependencies, return clear messages such as:
|
||||
- `DOCX parsing dependency missing: python-docx`
|
||||
- `PDF parsing dependency missing/configuration invalid`
|
||||
- Keep true unexpected exceptions as 500s
|
||||
|
||||
Files:
|
||||
- `backend/app/routers/document.py`
|
||||
- `backend/app/services/document_service.py`
|
||||
|
||||
## Testing Plan
|
||||
### Backend unit/integration tests
|
||||
1. Schema migration test for new `documents` columns
|
||||
2. Renderer tests:
|
||||
- markdown headings preserved
|
||||
- section paths retained in metadata
|
||||
- xlsx/csv table blocks rendered predictably
|
||||
- pdf page markers preserved from MinerU mapping
|
||||
3. Upload tests:
|
||||
- successful DOCX/XLSX/CSV/MD/TXT upload stores `normalized_content`
|
||||
- PDF upload stores `normalized_content`
|
||||
- missing dependency returns clear error instead of generic 500 where applicable
|
||||
4. Rebuild/reindex tests:
|
||||
- normalized content regenerated
|
||||
- chunks rebuilt with hierarchy metadata
|
||||
5. Retrieval tests:
|
||||
- related chunk lookup still works with enriched metadata
|
||||
|
||||
### Frontend tests
|
||||
Only if the UI surfaces normalized preview directly in this phase:
|
||||
- knowledge view preview prefers normalized content from API
|
||||
- no regression in upload and refresh persistence behavior
|
||||
|
||||
## Suggested Execution Order
|
||||
1. Add schema fields + migration guard
|
||||
2. Add structured markdown renderer for current parsers
|
||||
3. Store normalized content on upload
|
||||
4. Update content preview to read normalized content first
|
||||
5. Enrich chunk metadata with lightweight hierarchy keys
|
||||
6. Integrate MinerU for PDF
|
||||
7. Add rebuild/backfill path
|
||||
8. Expand tests
|
||||
|
||||
## Risks and Mitigations
|
||||
### Risk: MinerU integration complexity
|
||||
Mitigation:
|
||||
- isolate MinerU to PDF branch only
|
||||
- keep internal ParsedDocument contract stable
|
||||
|
||||
### Risk: markdown rendering loses structure
|
||||
Mitigation:
|
||||
- preserve critical structure in metadata
|
||||
- use explicit block markers for page/sheet/table boundaries
|
||||
|
||||
### Risk: broad retrieval regressions
|
||||
Mitigation:
|
||||
- keep chunking source node-based initially
|
||||
- change one layer at a time
|
||||
|
||||
### Risk: old documents lack normalized content
|
||||
Mitigation:
|
||||
- lazy backfill during preview/reindex
|
||||
|
||||
## Deliverable Recommendation
|
||||
Implement in small PR-sized slices:
|
||||
1. schema + normalized renderer + preview fallback
|
||||
2. hierarchy metadata enrichment
|
||||
3. MinerU PDF integration
|
||||
4. rebuild/backfill tooling
|
||||
@@ -1,215 +0,0 @@
|
||||
# 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 消耗统计
|
||||
@@ -1,903 +0,0 @@
|
||||
# 注册界面 + 设置界面 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(数据库迁移)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,982 +0,0 @@
|
||||
# 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` |
|
||||
@@ -1,739 +0,0 @@
|
||||
# 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` |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,941 +0,0 @@
|
||||
# 知识库文件夹分层实施计划
|
||||
|
||||
> **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)
|
||||
@@ -1,764 +0,0 @@
|
||||
# LLM Config Table UI 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:** 将 Settings 页面的 LLM 模型配置从卡片列表改为表格行内编辑形式
|
||||
|
||||
**Architecture:** 使用 Vue 3 Composition API,在 SettingsView.vue 内实现 4 个 LLM 类型区的表格组件,每种类型独立成区,支持行内展开编辑、测试连接、保存操作。
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Lucide Icons
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── views/
|
||||
│ └── SettingsView.vue # 重构:表格行内编辑 UI(所有逻辑内聚在此文件)
|
||||
└── components/settings/
|
||||
└── LLMTableRow.vue # 表格行组件(抽取以保持 SettingsView.vue 简洁)
|
||||
|
||||
backend/app/
|
||||
├── routers/settings.py # 确认测试 API 存在
|
||||
└── services/settings_service.py # 确认无需修改
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 验证后端测试 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/settings.py`
|
||||
- Modify: `backend/app/services/settings_service.py`
|
||||
|
||||
- [ ] **Step 1: 确认 `/api/settings/llm/test` 端点存在**
|
||||
|
||||
检查 `backend/app/routers/settings.py` 中是否有 `POST /api/settings/llm/test` 路由。
|
||||
|
||||
- [ ] **Step 2: 确认 `test_llm_connection` 函数存在**
|
||||
|
||||
检查 `backend/app/services/settings_service.py` 中是否有 `test_llm_connection` 函数。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git log --oneline -1
|
||||
# 如果确认后端无需修改:
|
||||
echo "Backend API already supports the new UI"
|
||||
# 如果发现问题需要修复:
|
||||
# git add backend/app/routers/settings.py
|
||||
# git commit -m "fix(settings): ensure test LLM API exists"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建 LLMTableRow 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/settings/LLMTableRow.vue`
|
||||
- Modify: `frontend/src/views/SettingsView.vue`
|
||||
|
||||
- [ ] **Step 1: 创建 LLMTableRow.vue 组件**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Eye, EyeOff, Play, ChevronDown, ChevronRight, Trash2 } from 'lucide-vue-next'
|
||||
import type { LLMModelConfig } from '@/api/settings'
|
||||
|
||||
const props = defineProps<{
|
||||
model: LLMModelConfig
|
||||
isExpanded: boolean
|
||||
isNew?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
(e: 'update', data: LLMModelConfig): void
|
||||
(e: 'delete'): void
|
||||
(e: 'test', data: LLMModelConfig): void
|
||||
}>()
|
||||
|
||||
const showApiKey = ref(false)
|
||||
|
||||
const status = computed(() => {
|
||||
if (!props.model.api_key || !props.model.model) return 'empty'
|
||||
if (props.model.enabled) return 'available'
|
||||
return 'unavailable'
|
||||
})
|
||||
|
||||
const statusConfig = computed(() => ({
|
||||
available: { icon: '●', color: '#10b981', label: '可用' },
|
||||
unavailable: { icon: '○', color: '#6b7280', label: '不可用' },
|
||||
empty: { icon: '⚠', color: '#ef4444', label: '未配置' }
|
||||
}[status.value]))
|
||||
|
||||
function onProviderChange() {
|
||||
const defaults: Record<string, string> = {
|
||||
ollama: 'http://localhost:11434',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
claude: 'https://api.anthropic.com',
|
||||
deepseek: 'https://api.deepseek.com/v1'
|
||||
}
|
||||
if (!props.model.base_url || Object.values(defaults).includes(props.model.base_url)) {
|
||||
emit('update', { ...props.model, base_url: defaults[props.model.provider] || '' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-row" :class="{ expanded: isExpanded, 'is-new': isNew }">
|
||||
<!-- 表格行(可点击展开) -->
|
||||
<div class="row-summary" @click="emit('toggle')">
|
||||
<div class="cell cell-toggle">
|
||||
<ChevronDown v-if="isExpanded" :size="14" />
|
||||
<ChevronRight v-else :size="14" />
|
||||
</div>
|
||||
<div class="cell cell-name">{{ model.name || '未命名' }}</div>
|
||||
<div class="cell cell-provider">{{ model.provider }}</div>
|
||||
<div class="cell cell-model">{{ model.model || '-' }}</div>
|
||||
<div class="cell cell-status" :style="{ color: statusConfig.color }">
|
||||
{{ statusConfig.icon }} {{ statusConfig.label }}
|
||||
</div>
|
||||
<div class="cell cell-actions" @click.stop>
|
||||
<button class="icon-btn danger" @click="emit('delete')" title="删除">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开的详情面板 -->
|
||||
<div v-if="isExpanded" class="expand-panel">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>// PROVIDER</label>
|
||||
<select v-model="model.provider" @change="onProviderChange">
|
||||
<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="model.model" type="text" placeholder="gpt-4o" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>// BASE URL</label>
|
||||
<input v-model="model.base_url" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>// API KEY</label>
|
||||
<div class="input-with-toggle">
|
||||
<input
|
||||
v-model="model.api_key"
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<button @click="showApiKey = !showApiKey">
|
||||
<Eye v-if="!showApiKey" :size="14" />
|
||||
<EyeOff v-else :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="test-btn" @click="emit('test', model)">
|
||||
<Play :size="12" /> 测试连接
|
||||
</button>
|
||||
<button
|
||||
class="save-btn"
|
||||
:disabled="status !== 'available'"
|
||||
@click="emit('update', model)"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button class="cancel-btn" @click="emit('toggle')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-row {
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.row-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-summary:hover {
|
||||
background: rgba(0,245,212,0.05);
|
||||
}
|
||||
|
||||
.cell {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cell-toggle { width: 30px; }
|
||||
.cell-name { flex: 1; min-width: 120px; }
|
||||
.cell-provider { width: 80px; }
|
||||
.cell-model { width: 120px; }
|
||||
.cell-status { width: 80px; }
|
||||
.cell-actions { width: 40px; text-align: right; }
|
||||
|
||||
.expand-panel {
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.input-with-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-toggle input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-with-toggle button {
|
||||
width: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.test-btn, .save-btn, .cancel-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0,245,212,0.3);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: rgba(0,245,212,0.1);
|
||||
border: 1px solid rgba(0,245,212,0.3);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-mid);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 SettingsView.vue 中引入 LLMTableRow**
|
||||
|
||||
```typescript
|
||||
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/settings/LLMTableRow.vue
|
||||
git commit -m "feat(settings): create LLMTableRow component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 重构 SettingsView.vue 主体结构
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/SettingsView.vue`
|
||||
|
||||
- [ ] **Step 1: 清理原有 LLM 配置相关代码**
|
||||
|
||||
删除原有的 `llmConfig` 卡片列表 UI(template 中的 `.model-list`、`.model-item` 等部分),保留 profile 和 scheduler 配置部分。
|
||||
|
||||
- [ ] **Step 2: 添加 LLM 配置状态管理**
|
||||
|
||||
```typescript
|
||||
// LLM 配置
|
||||
const llmConfig = ref<LLMConfig>({
|
||||
chat: [],
|
||||
vlm: [],
|
||||
embedding: [],
|
||||
rerank: []
|
||||
})
|
||||
|
||||
// 原始配置(用于比较变更)
|
||||
const originalLlmConfig = ref<LLMConfig>({ chat: [], vlm: [], embedding: [], rerank: [] })
|
||||
|
||||
// 展开的行
|
||||
const expandedRow = ref<string | null>(null) // 'chat-0', 'vlm-0' 等
|
||||
|
||||
// 当前正在编辑的模型快照(用于取消时恢复)
|
||||
const editingSnapshot = ref<{ type: string; index: number; data: LLMModelConfig } | null>(null)
|
||||
|
||||
// 必填警告
|
||||
const showRequiredWarning = computed(() => {
|
||||
return llmConfig.value.chat.length === 0 ||
|
||||
llmConfig.value.embedding.length === 0 ||
|
||||
llmConfig.value.rerank.length === 0
|
||||
})
|
||||
|
||||
// 行标识
|
||||
function getRowKey(type: string, index: number): string {
|
||||
return `${type}-${index}`
|
||||
}
|
||||
|
||||
// 切换行展开
|
||||
function toggleRow(type: string, index: number, model: LLMModelConfig) {
|
||||
const key = getRowKey(type, index)
|
||||
if (expandedRow.value === key) {
|
||||
expandedRow.value = null
|
||||
editingSnapshot.value = null
|
||||
} else {
|
||||
// 保存快照用于取消
|
||||
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) }
|
||||
expandedRow.value = key
|
||||
}
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
function cancelEdit(type: string, index: number) {
|
||||
if (editingSnapshot.value && editingSnapshot.value.type === type && editingSnapshot.value.index === index) {
|
||||
// 恢复原始数据
|
||||
llmConfig.value[type as keyof LLMConfig]![index] = editingSnapshot.value.data
|
||||
}
|
||||
expandedRow.value = null
|
||||
editingSnapshot.value = null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现添加模型**
|
||||
|
||||
```typescript
|
||||
function addModel(type: string) {
|
||||
if (!llmConfig.value[type as keyof LLMConfig]) {
|
||||
llmConfig.value[type as keyof LLMConfig] = []
|
||||
}
|
||||
// embedding/rerank 最多 1 个
|
||||
if ((type === 'embedding' || type === 'rerank') &&
|
||||
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
|
||||
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
|
||||
return
|
||||
}
|
||||
const newModel: LLMModelConfig = {
|
||||
name: `${type.toUpperCase()}-${Date.now()}`,
|
||||
provider: 'openai',
|
||||
model: type === 'chat' ? 'gpt-4o' : type === 'vlm' ? 'gpt-4o' : type === 'embedding' ? 'text-embedding-3-small' : 'bge-reranker-v2',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
enabled: false
|
||||
}
|
||||
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
|
||||
// 自动展开新添加的行
|
||||
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
|
||||
expandedRow.value = getRowKey(type, newIndex)
|
||||
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现删除模型**
|
||||
|
||||
```typescript
|
||||
function removeModel(type: string, index: number) {
|
||||
// embedding/rerank 为知识库必填,至少保留 1 个
|
||||
if ((type === 'embedding' || type === 'rerank') &&
|
||||
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
|
||||
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
|
||||
return
|
||||
}
|
||||
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
|
||||
expandedRow.value = null
|
||||
editingSnapshot.value = null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现更新模型**
|
||||
|
||||
```typescript
|
||||
function updateModel(type: string, index: number, model: LLMModelConfig) {
|
||||
llmConfig.value[type as keyof LLMConfig]![index] = model
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 实现测试连接**
|
||||
|
||||
```typescript
|
||||
async function testModel(type: string, index: number, model: LLMModelConfig) {
|
||||
try {
|
||||
const res = await settingsApi.testLLM({ type: type as any, ...model })
|
||||
if (res.data.success) {
|
||||
// 测试通过,标记为可用
|
||||
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
|
||||
showToast('连接成功')
|
||||
} else {
|
||||
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
||||
showToast(`连接失败: ${res.data.error}`, 'error')
|
||||
}
|
||||
} catch (e) {
|
||||
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
||||
showToast('测试连接失败', 'error')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 实现保存模型**
|
||||
|
||||
```typescript
|
||||
async function saveModel(type: string, index: number) {
|
||||
const key = getRowKey(type, index)
|
||||
savingModel.value = key
|
||||
try {
|
||||
await settingsApi.updateLLM(llmConfig.value)
|
||||
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
||||
expandedRow.value = null
|
||||
editingSnapshot.value = null
|
||||
showToast('保存成功')
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
|
||||
showToast(msg, 'error')
|
||||
} finally {
|
||||
savingModel.value = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 编写 template 的 LLM Config Section**
|
||||
|
||||
在 Profile section 之后,Scheduler section 之前添加:
|
||||
|
||||
```html
|
||||
<!-- LLM Config Section -->
|
||||
<div class="settings-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">// LLM CONFIGURATION</span>
|
||||
</div>
|
||||
|
||||
<!-- 必填警告 -->
|
||||
<div v-if="showRequiredWarning" class="warning-bar">
|
||||
⚠ chat / embedding / rerank 为知识库必填,请确保已配置
|
||||
</div>
|
||||
|
||||
<!-- Chat Section -->
|
||||
<div class="llm-type-section">
|
||||
<div class="llm-type-header">
|
||||
<span class="llm-type-title">CHAT</span>
|
||||
<button class="add-btn" @click="addModel('chat')">
|
||||
<Plus :size="12" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="llmConfig.chat && llmConfig.chat.length > 0" class="model-table">
|
||||
<div v-for="(model, index) in llmConfig.chat" :key="index">
|
||||
<LLMTableRow
|
||||
:model="model"
|
||||
:is-expanded="expandedRow === getRowKey('chat', index)"
|
||||
@toggle="toggleRow('chat', index, model)"
|
||||
@update="(m) => updateModel('chat', index, m)"
|
||||
@delete="removeModel('chat', index)"
|
||||
@test="(m) => testModel('chat', index, m)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无 chat 模型配置</div>
|
||||
</div>
|
||||
|
||||
<!-- VLM Section -->
|
||||
<div class="llm-type-section">
|
||||
<div class="llm-type-header">
|
||||
<span class="llm-type-title">VLM <span class="optional-tag">(可选)</span></span>
|
||||
<button class="add-btn" @click="addModel('vlm')">
|
||||
<Plus :size="12" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="llmConfig.vlm && llmConfig.vlm.length > 0" class="model-table">
|
||||
<div v-for="(model, index) in llmConfig.vlm" :key="index">
|
||||
<LLMTableRow
|
||||
:model="model"
|
||||
:is-expanded="expandedRow === getRowKey('vlm', index)"
|
||||
@toggle="toggleRow('vlm', index, model)"
|
||||
@update="(m) => updateModel('vlm', index, m)"
|
||||
@delete="removeModel('vlm', index)"
|
||||
@test="(m) => testModel('vlm', index, m)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无 vlm 模型配置</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedding Section -->
|
||||
<div class="llm-type-section">
|
||||
<div class="llm-type-header">
|
||||
<span class="llm-type-title">EMBEDDING <span class="required-tag">(知识库)</span></span>
|
||||
<button class="add-btn" @click="addModel('embedding')">
|
||||
<Plus :size="12" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="llmConfig.embedding && llmConfig.embedding.length > 0" class="model-table">
|
||||
<div v-for="(model, index) in llmConfig.embedding" :key="index">
|
||||
<LLMTableRow
|
||||
:model="model"
|
||||
:is-expanded="expandedRow === getRowKey('embedding', index)"
|
||||
@toggle="toggleRow('embedding', index, model)"
|
||||
@update="(m) => updateModel('embedding', index, m)"
|
||||
@delete="removeModel('embedding', index)"
|
||||
@test="(m) => testModel('embedding', index, m)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无 embedding 模型配置</div>
|
||||
</div>
|
||||
|
||||
<!-- Rerank Section -->
|
||||
<div class="llm-type-section">
|
||||
<div class="llm-type-header">
|
||||
<span class="llm-type-title">RERANK <span class="required-tag">(知识库)</span></span>
|
||||
<button class="add-btn" @click="addModel('rerank')">
|
||||
<Plus :size="12" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="llmConfig.rerank && llmConfig.rerank.length > 0" class="model-table">
|
||||
<div v-for="(model, index) in llmConfig.rerank" :key="index">
|
||||
<LLMTableRow
|
||||
:model="model"
|
||||
:is-expanded="expandedRow === getRowKey('rerank', index)"
|
||||
@toggle="toggleRow('rerank', index, model)"
|
||||
@update="(m) => updateModel('rerank', index, m)"
|
||||
@delete="removeModel('rerank', index)"
|
||||
@test="(m) => testModel('rerank', index, m)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无 rerank 模型配置</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 9: 添加相关样式**
|
||||
|
||||
```css
|
||||
/* LLM Type Section */
|
||||
.llm-type-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.llm-type-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.llm-type-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.optional-tag {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.required-tag {
|
||||
font-size: 9px;
|
||||
color: var(--accent-red);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Warning Bar */
|
||||
.warning-bar {
|
||||
padding: 10px 14px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-red);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Model Table */
|
||||
.model-table {
|
||||
/* 表格容器 */
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
border: 1px dashed var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 10: 更新 loadSettings 函数**
|
||||
|
||||
确保 LLM 配置正确加载:
|
||||
|
||||
```typescript
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await settingsApi.get()
|
||||
profile.value = {
|
||||
email: res.data.profile.email,
|
||||
full_name: res.data.profile.full_name || '',
|
||||
created_at: res.data.profile.created_at
|
||||
}
|
||||
originalProfile.value = { ...profile.value }
|
||||
|
||||
// 加载 LLM 配置
|
||||
if (res.data.llm_config) {
|
||||
llmConfig.value = {
|
||||
chat: res.data.llm_config.chat || [],
|
||||
vlm: res.data.llm_config.vlm || [],
|
||||
embedding: res.data.llm_config.embedding || [],
|
||||
rerank: res.data.llm_config.rerank || []
|
||||
}
|
||||
} else {
|
||||
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
|
||||
}
|
||||
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
||||
|
||||
if (res.data.scheduler_config && Object.keys(res.data.scheduler_config).length > 0) {
|
||||
schedulerConfig.value = res.data.scheduler_config as SchedulerConfig
|
||||
}
|
||||
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
|
||||
} catch (e) {
|
||||
console.error('加载设置失败', e)
|
||||
showToast('加载设置失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 11: 注册组件**
|
||||
|
||||
```typescript
|
||||
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
|
||||
// 在 components 中注册
|
||||
components: { LLMTableRow }
|
||||
```
|
||||
|
||||
- [ ] **Step 12: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/SettingsView.vue
|
||||
git commit -m "feat(settings): refactor LLM config to table inline-edit UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 测试和验证
|
||||
|
||||
**Files:**
|
||||
- Test: `frontend/src/views/SettingsView.vue`
|
||||
|
||||
- [ ] **Step 1: 手动测试流程**
|
||||
|
||||
1. 打开 Settings 页面
|
||||
2. 确认 chat/embedding/rerank 必填警告(如果为空)
|
||||
3. 添加新模型,点击 [+] 按钮
|
||||
4. 填写模型信息,点击"测试连接"
|
||||
5. 测试通过后,"保存"按钮可用
|
||||
6. 保存成功,刷新页面确认数据持久化
|
||||
7. 点击"取消"验证表单数据恢复
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(settings): complete LLM config table UI implementation"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,150 +0,0 @@
|
||||
# 2026-04-03 L3 Runtime Hardening Plan
|
||||
|
||||
## Goal
|
||||
先把 Jarvis 的 L3 主链夯实,只处理 runtime / graph / tools / service integration / tests / docs 的一致性问题;暂不继续扩 unrelated feature domain。
|
||||
|
||||
## Scope
|
||||
- `backend/app/agents/graph.py`
|
||||
- `backend/app/agents/state.py`
|
||||
- `backend/app/agents/tools/__init__.py`
|
||||
- `backend/app/agents/tools/search.py`
|
||||
- `backend/app/agents/tools/schedule.py`
|
||||
- `backend/app/agents/tools/task.py`
|
||||
- `backend/app/services/agent_service.py`
|
||||
- `backend/app/services/document_service.py`
|
||||
- `backend/app/services/memory_service.py`
|
||||
- `backend/tests/backend/app/agents/test_graph*.py`
|
||||
- `backend/tests/backend/app/services/test_brain_ingestion.py`
|
||||
- related design/plan docs under `docs/superpowers/`
|
||||
|
||||
## Non-goals
|
||||
- 不在本轮新增前端页面
|
||||
- 不在 L3 未稳定前继续扩 accounting / weather / RSS 等运行时域
|
||||
- 不重做 graph 架构,只做收敛、对齐和补测试
|
||||
|
||||
## Current High-Priority Gaps
|
||||
1. **continuity / clarification schema drift**
|
||||
- graph runtime 已使用 `owning_agent` / `owning_sub_commander` / `target_action`
|
||||
- brain ingestion tests 仍大量使用旧快照字段:`active_sub_flow` / `awaiting_user_input` 等
|
||||
2. **tool execution drift**
|
||||
- `search.py` 的 `_run_async()` 在 running loop 下实现不一致
|
||||
- schedule/task canonicalization 仍存在参数映射漂移
|
||||
3. **service integration drift**
|
||||
- `agent_service` 已派生 role-scoped memory sections,但 continuity snapshot / graph runtime / persisted attachments 需要继续收口
|
||||
4. **docs drift**
|
||||
- 现有文档已记录 L3 merge progress,但缺少一份当天可执行的 hardening 追踪文档
|
||||
|
||||
## Workstreams
|
||||
|
||||
### Workstream A — Continuity Contract
|
||||
Owner: worker-1
|
||||
|
||||
Target:
|
||||
- 对齐 clarification / continuity canonical schema
|
||||
- 让 graph runtime 与 persisted snapshot 使用同一套契约,或显式兼容旧字段
|
||||
- 补针对性测试
|
||||
|
||||
Done when:
|
||||
- graph 与 ingestion tests 对 clarification/continuity 断言一致
|
||||
- stale continuity / resume-after-clarification 场景有回归覆盖
|
||||
- 文档明确列出 canonical 字段和兼容规则
|
||||
|
||||
### Workstream B — Tool Execution Path
|
||||
Owner: worker-2
|
||||
|
||||
Target:
|
||||
- 修复 search async bridge
|
||||
- 对齐 task / schedule canonicalization
|
||||
- 固定当前 L3 scope 下真实支持的 tool/fallback 规则
|
||||
|
||||
Current status:
|
||||
- 已统一 `search.py` / `schedule.py` / `task.py` 到共享 `app.agents.tools.async_bridge.run_async`,避免 running loop 下的同步桥接漂移。
|
||||
- 已收敛 graph canonicalization:`create_todo` 保留 date/todo_date 语义;仅在出现 timed task 信号时提升为 `create_schedule_task`;`create_goal` 统一落到 `goal_date`;`create_reminder` clarification 前会先标准化 `date`。
|
||||
- 已补 targeted regressions,覆盖 active event loop search path、timed todo promotion、reminder clarification date normalization。
|
||||
|
||||
Done when:
|
||||
- 相关工具测试通过
|
||||
- graph canonicalization 行为清晰且无死分支
|
||||
- 文档明确说明支持的 tool path 与 deferred domains
|
||||
|
||||
### Workstream C — Service Integration
|
||||
Owner: worker-3
|
||||
|
||||
Target:
|
||||
- 对齐 graph runtime 与 `agent_service` 入口语义
|
||||
- 收敛 continuity snapshot、role-scoped context、stream/sync 行为
|
||||
- 补接入层测试或针对性断言
|
||||
|
||||
Done when:
|
||||
- `agent_service` 与 graph 状态注入规则一致
|
||||
- continuity snapshot load/persist 行为有测试证据
|
||||
- 文档明确 graph/service 边界和责任
|
||||
|
||||
## Runtime Contract Notes
|
||||
### Clarification context
|
||||
Canonical target shape:
|
||||
- `owning_agent`
|
||||
- `owning_sub_commander`
|
||||
- `target_action`
|
||||
- `question`
|
||||
- `partial_args`
|
||||
- `missing_fields`
|
||||
- `status`
|
||||
|
||||
### Continuity state
|
||||
Current known active markers:
|
||||
- `status: fresh|stale`
|
||||
- `mode: resume_after_clarification` for clarification continuation
|
||||
- routing continuation should only survive when the new request is still semantically a continuation
|
||||
|
||||
### Tool strategy
|
||||
Current target contract:
|
||||
- native tools and JSON fallback should converge on the same normalized tool name + normalized args before execution
|
||||
- system messages should remain coalesced into one system message for OpenAI-compatible providers that reject multiple system messages
|
||||
- sync tool shims in current L3 scope must route through shared `async_bridge.run_async` instead of per-file event-loop wrappers
|
||||
|
||||
### Current L3 tool path rules
|
||||
- `librarian_retrieval` current allowlist: `search_knowledge`, `hybrid_search`, `web_search`, `get_knowledge_graph_context`
|
||||
- search-family sync wrappers must be safe under an already-running event loop
|
||||
- `create_todo` keeps day-level intent on `todo_date`; do not silently remap date-only todo requests to task due dates
|
||||
- `create_todo` upgrades to `create_schedule_task` only for timed/task-shaped payloads such as `due_time`, `due_datetime`, `start_time`, `end_time`
|
||||
- `create_goal` date aliases normalize to `goal_date`
|
||||
- `create_reminder` aliases normalize before clarification so resumed flows keep canonical partial args
|
||||
|
||||
### Explicitly deferred domains in this hardening pass
|
||||
- accounting runtime expansion
|
||||
- weather runtime expansion
|
||||
- RSS runtime expansion
|
||||
- any new tool domains outside current schedule / task / forum / knowledge L3 path
|
||||
|
||||
## Documentation Rule For This Hardening Pass
|
||||
每完成一个 workstream:
|
||||
1. 更新本文件的 status
|
||||
2. 在相关 spec/notes 中补一段“当前状态 / 已决策 / 已知边界”
|
||||
3. 再标记任务完成
|
||||
|
||||
## Status
|
||||
- [x] Hardening tracker created
|
||||
- [x] Workstream A complete
|
||||
- [x] Workstream B complete
|
||||
- [x] Workstream C complete
|
||||
- [x] Final verification pass complete
|
||||
|
||||
## Verification Checklist
|
||||
- [x] `test_graph_system_messages.py` → 8 passed
|
||||
- [x] `test_tool_async_bridge.py` + `test_task_tools.py` → 18 passed
|
||||
- [x] `test_brain_ingestion.py` full file → 40 passed
|
||||
- [x] targeted continuity persistence/rehydration checks → 3 passed
|
||||
- [x] targeted graph regressions for timed todo / reminder clarification / active event loop paths
|
||||
- [ ] broader graph suite beyond this L3 slice
|
||||
|
||||
## Final Notes
|
||||
- L3 continuity persistence now uses one canonical envelope and normalizes legacy snapshot shapes on rehydration.
|
||||
- Service/runtime integration is aligned on the canonical continuity schema rather than legacy raw snapshot persistence.
|
||||
- Tool sync shims now share one async bridge across search / schedule / task / forum paths.
|
||||
- Final verification was executed with `uv run pytest` from `backend/`, which bypassed the broken plain `python` launcher in this environment.
|
||||
- A reviewer flagged async bridge timeout/cancellation semantics as a follow-up reliability concern for mutating tools, but it is not blocking this L3 hardening pass.
|
||||
|
||||
## Next Action
|
||||
- Treat this L3 hardening slice as complete.
|
||||
- If continuing, the next best follow-up is either broader graph regression coverage or a dedicated fix for async bridge timeout/cancellation semantics.
|
||||
@@ -1,83 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,192 +0,0 @@
|
||||
# 沟通系统增强设计
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -1,178 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,602 +0,0 @@
|
||||
# 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 助理系统的初始设计规格,将根据开发进展持续更新。*
|
||||
@@ -1,57 +0,0 @@
|
||||
# Notes: Jarvis Knowledge Brain Blueprint
|
||||
|
||||
## Current-State Findings
|
||||
- Existing source domains already exist separately: conversations, documents, todos, tasks, forum posts.
|
||||
- Current long-term memory only comes from conversation extraction via `UserMemory`.
|
||||
- Current graph build path only uses indexed document chunks.
|
||||
- Scheduler infrastructure already exists and can host daily brain-learning jobs.
|
||||
- Frontend already exposes a `知识大脑` navigation entry, but it currently points to the graph page.
|
||||
|
||||
## Synthesized Findings
|
||||
|
||||
### What can be reused
|
||||
- `memory_service` as a seed for conversation extraction and recall.
|
||||
- `scheduler_service` as the base for daily learning workflows.
|
||||
- `tag_service` as an early foundation for brain tags.
|
||||
- Existing business tables as authoritative raw source records.
|
||||
|
||||
### What is missing
|
||||
- Unified event layer across all source systems.
|
||||
- Candidate memory layer between raw events and durable brain memory.
|
||||
- Timeline-aware memory model with reinforcement / archival states.
|
||||
- Retrieval path that combines long-term memory with recent relevant events.
|
||||
- Brain-specific APIs and a dedicated frontend dashboard module.
|
||||
|
||||
### Phase 1 objective
|
||||
- Build the minimum architecture needed for a real event-driven brain:
|
||||
- BrainEvent
|
||||
- BrainCandidate
|
||||
- BrainMemory
|
||||
- BrainTag and link tables
|
||||
- ingestion services
|
||||
- daily learning job
|
||||
- retrieval integration
|
||||
- brain dashboard APIs
|
||||
|
||||
## Additional Findings: Knowledge Parsing Normalization
|
||||
- Current document ingestion parses each format separately and builds chunks directly from ParsedNode items.
|
||||
- Current chunks already carry structural metadata, but there is no explicit parent-child chunk graph.
|
||||
- The agreed direction is to use MinerU for PDF only, keep existing parsers for DOCX/XLSX/CSV/MD/TXT, and converge all outputs into structured markdown.
|
||||
- normalized_content should be persisted on documents so preview, rebuild, and future chunking can reuse the same canonical text.
|
||||
- Lightweight hierarchy should be represented in chunk metadata first, not in a new relational tree schema.
|
||||
- Current DOCX upload failure in the running environment is caused by a missing python-docx installation in the active backend environment.
|
||||
|
||||
## Additional Findings: L3 Merge Progress
|
||||
- `backend/app/agents/state.py` has been expanded to the newer L3 runtime state shape so graph/runtime code can rely on structured continuity, tool-round, retry, routing-hop, and datetime-reference fields.
|
||||
- `backend/app/agents/graph.py` no longer contains merge markers and the phantom `EXECUTOR_ACCOUNTING` branch has been removed from graph registration and routing.
|
||||
- Accounting-style prompts are currently normalized onto `AgentRole.EXECUTOR` instead of a separate executor-accounting role, which avoids dangling enum/runtime references while keeping those intents routable.
|
||||
- `backend/tests/backend/app/agents/test_graph.py` has been reconciled onto the newer L3 runtime test branch and stale `EXECUTOR_ACCOUNTING` expectations were updated to `AgentRole.EXECUTOR`.
|
||||
- Tool execution now uses a shared async bridge in `backend/app/agents/tools/async_bridge.py`, and `search.py`, `schedule.py`, `task.py`, plus `forum.py` all route synchronous tool entrypoints through that same bridge to keep runtime behavior consistent inside and outside active event loops.
|
||||
- Current task/schedule canonicalization remains intentionally narrow for L3: task aliases (`content`, `date`, legacy priorities) and reminder aliases (`datetime`, `at`, `remind_at`, `time`, timezone variants) are normalized; deferred domains such as weather/accounting-specific tool routing remain outside this stabilization slice.
|
||||
- Targeted verification now covers async bridge behavior plus task/schedule alias persistence tests; local pytest invocation still depends on resolving environment-level startup issues when the interpreter exits before running the selected files.
|
||||
- L3 runtime/service integration now persists continuity snapshots in a single canonical envelope (`kind`, `version`, `state`) on both assistant message attachments and `Conversation.agent_state`, so streaming and sync chat entrypoints rehydrate the same shape.
|
||||
- The continuity rehydration path is also tolerant of older `Conversation` rows/models that do not expose `agent_state`, falling back to assistant message attachments instead of failing before graph execution.
|
||||
- The finalized L3 continuity contract persists a canonical `agent_continuity_state` snapshot: `turn_context.active_sub_commander`, `pending_action.type|owner_agent|owner_sub_commander|status`, `clarification_context.owning_agent|owning_sub_commander|target_action|question|status`, and `continuity_state.status|mode`.
|
||||
- `backend/app/services/agent_service.py` normalizes legacy persisted snapshots (`active_sub_flow`, `agent`, `sub_flow`, `action_type`, `awaiting_user_input`, `awaiting_clarification`) into that canonical shape on both save and rehydration so older brain-ingestion records still resume correctly.
|
||||
- Edge cases: explicit new requests may keep stale continuity in memory for override-aware routing, but only `continuity_state.status == fresh` participates in active continuation; clarification resumes use `continuity_state.mode = resume_after_clarification`.
|
||||
- `memory_service.build_memory_context(...)` remains the shared retrieval join point for conversation summaries, user memory, and BrainMemory recall, while `document_service` continues emitting BrainEvent records from upload flow without changing the graph runtime contract.
|
||||
@@ -1,427 +0,0 @@
|
||||
# Jarvis Knowledge Brain Phase 1 Blueprint
|
||||
|
||||
## 1. Phase 1 Goal
|
||||
Phase 1 establishes the first production-ready version of Jarvis's event-driven knowledge brain. The objective is not to finish the entire intelligence system, but to create the minimum architecture that lets Jarvis ingest key user actions from across the product, learn from them on a daily schedule, store only high-value knowledge, and retrieve that knowledge during future conversations.
|
||||
|
||||
Phase 1 should make the brain real in six ways:
|
||||
1. unify source events across core modules;
|
||||
2. create an intermediate candidate-learning layer;
|
||||
3. promote durable knowledge into long-term brain memory;
|
||||
4. maintain tags and time-aware traceability;
|
||||
5. expose APIs for inspection and management;
|
||||
6. allow the chat system to retrieve brain knowledge during answers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope Boundaries
|
||||
|
||||
### In scope
|
||||
- New persistence models for brain events, candidates, memories, tags, and relationships.
|
||||
- Ingestion of source signals from conversations, knowledge documents, todos, kanban tasks, and forum posts.
|
||||
- A daily autonomous learning pipeline that tags, scores, deduplicates, and upgrades knowledge.
|
||||
- Retrieval integration for future responses.
|
||||
- Brain dashboard APIs.
|
||||
- A new frontend brain module structure replacing the current graph-only mental model.
|
||||
|
||||
### Out of scope for phase 1
|
||||
- Full graph-native reasoning engine.
|
||||
- Fully autonomous suggestion orchestration across all screens.
|
||||
- Complex reinforcement-learning style adaptation.
|
||||
- Fine-grained user-tunable learning policy UI.
|
||||
- Automatic deletion and archival heuristics beyond simple status transitions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target Architecture
|
||||
Phase 1 should introduce a four-layer brain pipeline:
|
||||
|
||||
1. **Source Records**
|
||||
Existing domain tables remain the source of truth: messages, documents/chunks, todos, tasks, forum posts/replies.
|
||||
|
||||
2. **BrainEvent**
|
||||
A normalized event layer representing meaningful user/system actions. This is the single intake format for downstream learning.
|
||||
|
||||
3. **BrainCandidate**
|
||||
AI-generated candidate knowledge distilled from one or more events. Candidates are scored, tagged, typed, and traced back to source events.
|
||||
|
||||
4. **BrainMemory**
|
||||
Durable long-term memory that Jarvis can retrieve during future interactions. This becomes the brain's core persistence layer.
|
||||
|
||||
Graph visualization should be treated as a **projection layer**, not the primary storage model. In later phases, graph nodes and edges can be generated from BrainMemory records and their relationships.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Model Additions
|
||||
|
||||
### 4.1 BrainEvent
|
||||
Purpose: normalized raw learning input.
|
||||
|
||||
Recommended fields:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `source_type` (`conversation`, `document`, `todo`, `task`, `forum_post`, `forum_reply`)
|
||||
- `source_id`
|
||||
- `event_type` (`created`, `updated`, `completed`, `mentioned`, `uploaded`, `resolved`, `marked_important`, etc.)
|
||||
- `occurred_at`
|
||||
- `event_date`
|
||||
- `title`
|
||||
- `content_summary`
|
||||
- `raw_excerpt`
|
||||
- `metadata_` (JSON; source-specific facts such as conversation_id, task status, folder path)
|
||||
- `importance_signal` (numeric seed score)
|
||||
- `is_user_pinned`
|
||||
- `processed_at`
|
||||
- `status` (`pending`, `processed`, `ignored`)
|
||||
|
||||
Indexes:
|
||||
- `(user_id, event_date)`
|
||||
- `(user_id, source_type, source_id)`
|
||||
- `(user_id, status, occurred_at)`
|
||||
|
||||
### 4.2 BrainCandidate
|
||||
Purpose: intermediate learned knowledge awaiting acceptance into durable memory.
|
||||
|
||||
Recommended fields:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `candidate_type` (`preference`, `habit`, `project_fact`, `decision`, `solution`, `topic`, `goal`, `temporary_focus`)
|
||||
- `title`
|
||||
- `summary`
|
||||
- `importance_score`
|
||||
- `confidence_score`
|
||||
- `time_scope` (`short_term`, `phase`, `long_term`)
|
||||
- `valid_from`
|
||||
- `valid_to`
|
||||
- `source_event_ids` (JSON array)
|
||||
- `reasoning_trace` (short explanation of why the system extracted it)
|
||||
- `status` (`new`, `promoted`, `rejected`, `merged`)
|
||||
- `created_at`
|
||||
- `reviewed_at`
|
||||
|
||||
### 4.3 BrainMemory
|
||||
Purpose: durable brain knowledge used at retrieval time.
|
||||
|
||||
Recommended fields:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `memory_type` (`preference`, `habit`, `goal`, `project_fact`, `decision`, `solution`, `topic_profile`)
|
||||
- `title`
|
||||
- `content`
|
||||
- `importance`
|
||||
- `confidence`
|
||||
- `timeline_date`
|
||||
- `first_learned_at`
|
||||
- `last_reinforced_at`
|
||||
- `reinforcement_count`
|
||||
- `status` (`active`, `archived`, `deleted`)
|
||||
- `origin_candidate_id`
|
||||
- `origin_source_types` (JSON array)
|
||||
- `metadata_` (JSON)
|
||||
|
||||
### 4.4 BrainTag
|
||||
Purpose: independent tagging layer for brain browsing, filtering, and scoring.
|
||||
|
||||
Recommended fields:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `name`
|
||||
- `category` (`topic`, `value`, `time`, `source`)
|
||||
- `priority` (`important`, `secondary`)
|
||||
- `score`
|
||||
- `last_seen_at`
|
||||
- `created_at`
|
||||
|
||||
### 4.5 Link Tables
|
||||
Add many-to-many link tables:
|
||||
- `brain_event_tags`
|
||||
- `brain_candidate_tags`
|
||||
- `brain_memory_tags`
|
||||
- optional `brain_memory_events` for direct memory-to-event traceability beyond JSON arrays
|
||||
|
||||
These link tables are critical because phase 1 needs tag filters and timeline tracing before advanced graph projection exists.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ingestion Strategy
|
||||
Phase 1 should not rewrite existing modules. Instead, it should add thin ingestion hooks near existing write paths.
|
||||
|
||||
### Conversation ingestion
|
||||
Trigger points:
|
||||
- after user message creation
|
||||
- after assistant completion
|
||||
- after memory extraction / summary creation
|
||||
|
||||
Event examples:
|
||||
- important user instruction
|
||||
- explicit “remember this” request
|
||||
- repeated topic cluster
|
||||
- conversation-derived decision or unresolved goal
|
||||
|
||||
### Document ingestion
|
||||
Trigger points:
|
||||
- after upload success
|
||||
- after indexing completes
|
||||
- after manual chunk edits
|
||||
|
||||
Event examples:
|
||||
- document uploaded
|
||||
- document indexed
|
||||
- high-value section discovered
|
||||
- document summary available
|
||||
|
||||
### Todo ingestion
|
||||
Trigger points:
|
||||
- todo created
|
||||
- todo completed
|
||||
- AI-generated todo created
|
||||
|
||||
Event examples:
|
||||
- planned work item
|
||||
- recurring operational duty
|
||||
- completion signal reflecting actual user focus
|
||||
|
||||
### Task/Kanban ingestion
|
||||
Trigger points:
|
||||
- task created
|
||||
- task status changed
|
||||
- task completed
|
||||
- priority changed
|
||||
|
||||
Event examples:
|
||||
- declared project goal
|
||||
- active workstream
|
||||
- resolved milestone
|
||||
|
||||
### Forum ingestion
|
||||
Trigger points:
|
||||
- post created
|
||||
- reply created
|
||||
- forum instruction executed or referenced
|
||||
|
||||
Event examples:
|
||||
- public project decision
|
||||
- repeated operational issue
|
||||
- reusable explanation or solution
|
||||
|
||||
Implementation note: source ingestion should create BrainEvent rows synchronously or via lightweight background tasks, but should not block the original user flow.
|
||||
|
||||
---
|
||||
|
||||
## 6. Learning and Promotion Pipeline
|
||||
Phase 1 should add a new daily scheduler workflow dedicated to the brain.
|
||||
|
||||
### New scheduler job: `brain_daily_learning_task`
|
||||
Suggested run: once daily after the bulk of user activity, for example 01:00 or configurable per user later.
|
||||
|
||||
Pipeline steps:
|
||||
1. collect unprocessed `BrainEvent` rows for the target date;
|
||||
2. cluster by source, topic, and repeated patterns;
|
||||
3. ask the LLM to produce candidate knowledge with tags and importance explanations;
|
||||
4. deduplicate against existing `BrainMemory` by semantic and rule-based matching;
|
||||
5. promote high-confidence candidates into `BrainMemory`;
|
||||
6. mark low-value candidates rejected or retained as observation-only;
|
||||
7. refresh tag scores and priority levels;
|
||||
8. mark consumed events as processed.
|
||||
|
||||
### Promotion rules for phase 1
|
||||
Promote automatically when any of these are true:
|
||||
- user explicitly requested the system to remember something;
|
||||
- the same topic appears across multiple sources;
|
||||
- a solution/decision was formed and looks reusable;
|
||||
- a stable preference or habit is seen repeatedly;
|
||||
- a task/todo/forum thread confirms relevance with user action.
|
||||
|
||||
Keep as candidate-only when:
|
||||
- information is recent but not yet stable;
|
||||
- importance is uncertain;
|
||||
- it appears only once without reinforcement.
|
||||
|
||||
Reject when:
|
||||
- content is obviously transient;
|
||||
- it is too generic to help future answers;
|
||||
- it duplicates active memory without adding new value.
|
||||
|
||||
---
|
||||
|
||||
## 7. Retrieval Integration
|
||||
Phase 1 must let chat use the brain in a controlled way.
|
||||
|
||||
### New retrieval service
|
||||
Add a dedicated `brain_retrieval_service` or extend `memory_service` with brain-aware retrieval APIs.
|
||||
|
||||
Responsibilities:
|
||||
- retrieve top relevant `BrainMemory` rows by query, tags, time context, and importance;
|
||||
- optionally retrieve recent `BrainEvent` summaries for recency-sensitive answers;
|
||||
- merge existing `UserMemory` and `MemorySummary` into one retrieval result shape;
|
||||
- support limits to avoid prompt bloat.
|
||||
|
||||
### Retrieval policy
|
||||
At answer time:
|
||||
- always consider long-term `BrainMemory`;
|
||||
- include recent event summaries only when the question appears time-sensitive or project-state-sensitive;
|
||||
- cap injected brain context to a small curated set.
|
||||
|
||||
Recommended first integration path:
|
||||
- extend `build_memory_context()` to append a new `【知识大脑】` block built from `BrainMemory` retrieval.
|
||||
- keep existing conversation summary logic intact.
|
||||
|
||||
This gives immediate product value without requiring a full prompt orchestration rewrite.
|
||||
|
||||
---
|
||||
|
||||
## 8. Backend Services to Add or Refactor
|
||||
|
||||
### New services
|
||||
1. `brain_event_service.py`
|
||||
- normalize incoming source data into BrainEvent rows
|
||||
- provide source-specific helper constructors
|
||||
|
||||
2. `brain_learning_service.py`
|
||||
- run daily candidate extraction
|
||||
- score, dedupe, and promote memories
|
||||
|
||||
3. `brain_tag_service.py`
|
||||
- manage tags, scoring, priority updates, and cleanup suggestions
|
||||
|
||||
4. `brain_retrieval_service.py`
|
||||
- retrieve relevant memories and recent events for chat and UI
|
||||
|
||||
### Existing services to extend
|
||||
- `memory_service.py`: integrate BrainMemory retrieval and possibly migrate `UserMemory` into the new model later
|
||||
- `scheduler_service.py`: register brain daily learning job
|
||||
- `agent_service.py`: inject retrieved brain context into chat pipeline
|
||||
- `document_service.py`, `todo_service.py`, task/forum write paths: emit BrainEvent rows
|
||||
|
||||
---
|
||||
|
||||
## 9. API Plan
|
||||
Phase 1 should add a dedicated `/api/brain` router.
|
||||
|
||||
### Read APIs
|
||||
- `GET /api/brain/overview`
|
||||
- counts: active memories, candidates, important tags, recent events
|
||||
- today's learning summary
|
||||
|
||||
- `GET /api/brain/memories`
|
||||
- filters: tag, type, status, date range, source type
|
||||
|
||||
- `GET /api/brain/candidates`
|
||||
- filters: status, date, score threshold
|
||||
|
||||
- `GET /api/brain/tags`
|
||||
- segmented into important and secondary
|
||||
|
||||
- `GET /api/brain/timeline`
|
||||
- grouped by day/week; includes events, candidate promotions, reinforced memories
|
||||
|
||||
- `GET /api/brain/memory/{id}`
|
||||
- full traceability including linked events and tags
|
||||
|
||||
### Write/management APIs
|
||||
- `POST /api/brain/memory/{id}/promote`
|
||||
- `POST /api/brain/memory/{id}/archive`
|
||||
- `DELETE /api/brain/memory/{id}`
|
||||
- `POST /api/brain/tag/{id}/promote`
|
||||
- `POST /api/brain/tag/{id}/demote`
|
||||
- `DELETE /api/brain/tag/{id}`
|
||||
- `POST /api/brain/learn/run`
|
||||
- manual trigger for daily learning pipeline
|
||||
|
||||
### Compatibility note
|
||||
Do not remove `/api/graph` in phase 1. Keep it as a legacy projection route while the new brain module is introduced.
|
||||
|
||||
---
|
||||
|
||||
## 10. Frontend Module Structure
|
||||
The current `知识大脑` nav item should stop meaning “graph only” and become a real brain dashboard.
|
||||
|
||||
### Route strategy
|
||||
Preferred phase 1 structure:
|
||||
- `/brain` → new knowledge brain dashboard
|
||||
- `/graph` → graph view tab or subview under the brain module, retained for relation visualization
|
||||
|
||||
### Brain dashboard sections
|
||||
1. **Overview header**
|
||||
- total active memories
|
||||
- today's learned items
|
||||
- important tags count
|
||||
- last learning run
|
||||
|
||||
2. **Important tags panel**
|
||||
- AI-ranked important tags
|
||||
- click to filter related memories and timeline entries
|
||||
|
||||
3. **Secondary tags panel**
|
||||
- lower-priority tags with cleanup actions
|
||||
|
||||
4. **Recent learned knowledge**
|
||||
- newly promoted memories
|
||||
- reasons and source badges
|
||||
|
||||
5. **Timeline panel**
|
||||
- daily grouped events and promotions
|
||||
- support time-based backtracking
|
||||
|
||||
6. **Graph subview**
|
||||
- optional tab or secondary panel for relation projection
|
||||
|
||||
### User actions in phase 1
|
||||
- delete memory
|
||||
- archive memory
|
||||
- promote/demote tag priority
|
||||
- manually trigger learning run
|
||||
- inspect why a memory exists
|
||||
|
||||
This is enough to make the brain visible and manageable even before advanced graph reasoning exists.
|
||||
|
||||
---
|
||||
|
||||
## 11. Suggested Delivery Breakdown
|
||||
|
||||
### Step 1: Persistence foundation
|
||||
- add brain models and migrations
|
||||
- add SQLAlchemy registrations and schemas
|
||||
|
||||
### Step 2: Event ingestion
|
||||
- emit BrainEvent rows from conversation/document/todo/task/forum flows
|
||||
|
||||
### Step 3: Learning workflow
|
||||
- implement daily learning job and manual trigger API
|
||||
|
||||
### Step 4: Retrieval integration
|
||||
- wire BrainMemory into chat context assembly
|
||||
|
||||
### Step 5: Brain dashboard backend
|
||||
- add overview, memories, tags, timeline endpoints
|
||||
|
||||
### Step 6: Brain dashboard frontend
|
||||
- add `/brain` page and move graph into a subview or separate tab
|
||||
|
||||
---
|
||||
|
||||
## 12. Risks and Guardrails
|
||||
|
||||
### Main risks
|
||||
- over-collection leading to noisy memories;
|
||||
- prompt bloat from injecting too much brain context;
|
||||
- duplicate memory creation across repeated daily runs;
|
||||
- unclear distinction between candidate and durable memory;
|
||||
- UI becoming graph-centric again instead of brain-centric.
|
||||
|
||||
### Guardrails
|
||||
- enforce candidate layer before promotion;
|
||||
- cap retrieval size strictly;
|
||||
- keep source traceability for every promoted memory;
|
||||
- make tag cleanup explicit in UI;
|
||||
- treat graph as a projection, not the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## 13. Phase 1 Success Criteria
|
||||
Phase 1 is successful when all of the following are true:
|
||||
- the system creates normalized BrainEvent rows from all five major source domains;
|
||||
- a scheduled daily learning job produces candidates and promotes high-value memories;
|
||||
- Jarvis can retrieve durable brain memories during future answers;
|
||||
- the frontend exposes a real brain dashboard with tags, recent knowledge, and timeline;
|
||||
- users can inspect and clean what the system learned;
|
||||
- the old graph page is no longer the only visible representation of the brain.
|
||||
@@ -1,141 +0,0 @@
|
||||
# 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 消耗统计
|
||||
@@ -1,249 +0,0 @@
|
||||
# 注册界面 + 设置界面 功能设计
|
||||
|
||||
## 概述
|
||||
|
||||
为 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. 注册表单验证正常 - 密码强度、格式校验有效
|
||||
@@ -1,267 +0,0 @@
|
||||
# 数据统计页面重新设计
|
||||
|
||||
## 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. 数据刷新
|
||||
|
||||
- 页面进入时加载所有数据
|
||||
- 支持手动刷新按钮(每个模块独立刷新)
|
||||
- 数字变化时无动画(避免干扰)
|
||||
@@ -1,473 +0,0 @@
|
||||
# 交互广场重新设计
|
||||
|
||||
## 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生成摘要 → 前端展示
|
||||
```
|
||||
@@ -1,307 +0,0 @@
|
||||
# 知识库文件夹分层设计
|
||||
|
||||
> **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` 做路径前缀匹配
|
||||
- 前端递归组件注意防止无限循环
|
||||
@@ -1,157 +0,0 @@
|
||||
# LLM 模型配置表格设计
|
||||
|
||||
## 1. 概述
|
||||
|
||||
重新设计 Settings 页面的 LLM 模型配置 UI,将原有的卡片列表改为表格行内编辑形式,简化交互、减少页面长度,同时支持多模型配置。
|
||||
|
||||
## 2. 需求
|
||||
|
||||
- **chat**: 必填,多个(子智能体可选不同模型)
|
||||
- **vlm**: 选填,多个
|
||||
- **embedding**: 必填,1 个(知识库专用)
|
||||
- **rerank**: 必填,1 个(知识库专用)
|
||||
|
||||
## 3. UI 设计
|
||||
|
||||
### 3.1 整体布局
|
||||
|
||||
每种 LLM 类型(chat/vlm/embedding/rerank)独立成区,区头部显示类型名称和必填/选填标识,右上角有 [+] 添加按钮。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ // LLM CONFIGURATION │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─ CHAT ─────────────────────────────────────────────── [+] │
|
||||
│ │ 名称 │ Provider │ 模型 │ 状态 │ 操作 │
|
||||
│ ├─────────────────────────────────────────────────────────┤ │
|
||||
│ │ Agent-Chat │ OpenAI │ gpt-4o │ ● 可用 │ ▶ ✕ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ ... (vlm, embedding, rerank 同理) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 表格列(精简版)
|
||||
|
||||
| 列 | 说明 |
|
||||
|----|------|
|
||||
| 名称 | 模型名称,支持输入编辑 |
|
||||
| Provider | 下拉选择:OpenAI / Claude / Ollama / DeepSeek / Custom |
|
||||
| 模型 | 模型名称,支持输入编辑 |
|
||||
| 状态 | ● 可用(绿色)/ ○ 不可用(灰色)/ ⚠ 必填未填(红色) |
|
||||
| 操作 | 展开详情按钮 ▶ / 删除按钮 ✕ |
|
||||
|
||||
### 3.3 行内展开详情面板
|
||||
|
||||
点击任意行,行下方展开详情表单:
|
||||
|
||||
```
|
||||
│ ▼ Agent-Chat │ OpenAI │ gpt-4o │ ● 可用 │ ▶ ✕ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Provider: [OpenAI ▼] Model: [gpt-4o ] │ │
|
||||
│ │ Base URL: [https://api.openai.com/v1 ] │ │
|
||||
│ │ API Key: [sk-•••••••••••••••• ] 👁 │ │
|
||||
│ │ │ │
|
||||
│ │ [▶ 测试连接] [保存] [取消] │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
```
|
||||
|
||||
### 3.4 状态说明
|
||||
|
||||
| 状态 | 颜色 | 含义 |
|
||||
|------|------|------|
|
||||
| ● 可用 | 绿色 | 测试通过 |
|
||||
| ○ 不可用 | 灰色 | 未测试或测试失败 |
|
||||
| ⚠ 必填未填 | 红色 | chat/embedding/rerank 未配置 |
|
||||
|
||||
### 3.5 警告提示
|
||||
|
||||
当 chat/embedding/rerank 任一类型为空时,表格顶部显示红色警告条:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ⚠ chat / embedding / rerank 为知识库必填,请确保已配置 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 交互规则
|
||||
|
||||
| 动作 | 行为 |
|
||||
|------|------|
|
||||
| 添加模型 | 点击 [+] 在对应类型底部添加新行,状态默认为 ○ 不可用 |
|
||||
| 展开编辑 | 点击任意行,行内展开详情面板,同时收起其他已展开的行 |
|
||||
| 测试连接 | 点击"测试连接",调用后端 API,测试通过则状态变 ● 可用,失败显示错误 Toast |
|
||||
| 保存 | 只有测试通过的模型才能保存,保存后更新 originalLlmConfig |
|
||||
| 删除 | 点击 ✕ 删除该模型(embedding/rerank 至少保留 1 个) |
|
||||
| 取消编辑 | 点击"取消"或再次点击展开按钮,收起详情面板,表单数据恢复原值 |
|
||||
| Provider 变化 | 自动填充对应 Provider 的默认 Base URL |
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
```typescript
|
||||
interface LLMModelConfig {
|
||||
name: string // 模型名称
|
||||
provider: 'openai' | 'claude' | 'ollama' | 'deepseek' | 'custom'
|
||||
model: string // 模型名称
|
||||
base_url: string // API Base URL
|
||||
api_key: string // API Key
|
||||
enabled: boolean // 是否启用
|
||||
}
|
||||
|
||||
interface LLMConfig {
|
||||
chat: LLMModelConfig[] // 必填,多个
|
||||
vlm: LLMModelConfig[] // 选填,多个
|
||||
embedding: LLMModelConfig[] // 必填,1个
|
||||
rerank: LLMModelConfig[] // 必填,1个
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 后端 API
|
||||
|
||||
### 6.1 保存策略
|
||||
|
||||
`saveModel(type, index)` 发送完整 `LLMConfig` 对象到后端,后端整体替换该类型的模型列表。
|
||||
|
||||
- chat/vlm: 列表直接替换
|
||||
- embedding/rerank: 列表直接替换(限制最多 1 个)
|
||||
|
||||
### 6.2 测试连接 API
|
||||
|
||||
```typescript
|
||||
POST /api/settings/llm/test
|
||||
{
|
||||
"type": "chat" | "vlm" | "embedding" | "rerank",
|
||||
"provider": "openai" | "claude" | "ollama" | "deepseek" | "custom",
|
||||
"model": "gpt-4o",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```typescript
|
||||
{ "success": true, "message": "连接成功" }
|
||||
{ "success": false, "error": "错误信息" }
|
||||
```
|
||||
|
||||
## 7. 组件结构
|
||||
|
||||
```
|
||||
SettingsView.vue
|
||||
├── LLMConfigSection (chat/vlm/embedding/rerank 四区)
|
||||
│ ├── LLMTypeCard (每个类型一个卡片)
|
||||
│ │ ├── LLMTable (表格头部 + 列表)
|
||||
│ │ │ ├── LLMTableRow (每行模型)
|
||||
│ │ │ └── LLMExpandPanel (展开的详情面板)
|
||||
│ │ └── LLMEmptyState (空状态 + 添加按钮)
|
||||
│ └── LLMWarning (必填警告条)
|
||||
```
|
||||
|
||||
## 8. 实现要点
|
||||
|
||||
1. **单行展开**: 点击行时收起其他已展开行,保持 UI 简洁
|
||||
2. **测试通过才可保存**: 保存按钮仅在 `model.enabled === true` 时可用
|
||||
3. **API Key 脱敏**: 列表中不显示 API Key,详情面板中默认隐藏(显示为 ••••)
|
||||
4. **Provider 默认 URL**: `onProviderChange` 自动填充默认值
|
||||
5. **深拷贝比较**: `isModelDirty` 使用 `JSON.stringify` 深拷贝比较
|
||||
6. **originalLlmConfig 同步**: 每次保存成功后更新原始配置副本
|
||||
@@ -1,288 +0,0 @@
|
||||
# Skill 系统设计方案
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
当前 Jarvis 系统采用基于 LangGraph 的多 Agent 架构(Master/Planner/Executor/Librarian/Analyst),通过关键词规则路由到子 Agent。系统缺乏可扩展的 Skill 机制,无法让 Agent 按需调用自定义能力。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
构建一个 **Skill 系统**,让每个 Agent 能够:
|
||||
- 挂载可配置的 Skill 能力插件
|
||||
- 由 LLM 自主判断何时使用哪个 Skill
|
||||
- 支持私有/团队共享/市场三种可见性
|
||||
- Skill 作为 Agent 的指令模板 + 工具组合
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心理念
|
||||
|
||||
**Skill 是 Agent 的"能力插件",由 LLM 自主决策调用时机。**
|
||||
|
||||
```
|
||||
用户: "帮我规划今天的工作"
|
||||
│
|
||||
▼
|
||||
Master Agent 理解意图
|
||||
│
|
||||
▼
|
||||
路由到 Planner Agent
|
||||
│
|
||||
▼
|
||||
Planner 分析任务 → 自主判断需要什么 Skill
|
||||
│
|
||||
├──→ 需要数据 → 调用 "数据获取" Skill
|
||||
├──→ 需要优先级 → 调用 "任务排序" Skill
|
||||
└──→ 需要时间安排 → 调用 "日程规划" Skill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 Skill 实体
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|-----|
|
||||
| id | UUID | 主键 |
|
||||
| name | str | Skill 名称,如 "任务排序" |
|
||||
| description | str | 供 LLM 理解该 Skill 的用途 |
|
||||
| instructions | str | Agent 执行时的系统指令模板 |
|
||||
| agent_type | AgentRole | 适用于哪个 Agent (master/planner/executor/librarian/analyst) |
|
||||
| tools | List[str] | 引用的工具名称列表 |
|
||||
| required_context | List[str] | 需要的前置数据 |
|
||||
| output_format | str | 输出格式要求(可选) |
|
||||
| visibility | enum | private/team/market |
|
||||
| team_id | UUID | 团队 ID(visibility=team 时使用) |
|
||||
| is_active | bool | 是否启用 |
|
||||
| owner_id | UUID | 创建者 ID |
|
||||
| created_at | datetime | 创建时间 |
|
||||
| updated_at | datetime | 更新时间 |
|
||||
|
||||
### 3.2 Agent-Skill 关联
|
||||
|
||||
每个 Agent 运行时从数据库加载其 `agent_type` 对应的所有 `is_active=True` 的 Skills,作为可选能力供 LLM 调用。
|
||||
|
||||
---
|
||||
|
||||
## 4. 系统架构
|
||||
|
||||
### 4.1 组件关系
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Agent Brain │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Master Agent (理解意图,路由到子 Agent) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ [Planner] [Executor] [Librarian] ... │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ └───────────┼────────────┘ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌──────────────────┐ │ │
|
||||
│ │ │ Skill Registry │ │ │
|
||||
│ │ │ (可用的 Skills) │ │ │
|
||||
│ │ └────────┬─────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ LLM 自主判断使用哪个 Skill │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 组件说明
|
||||
|
||||
| 组件 | 职责 |
|
||||
|-----|------|
|
||||
| Skill Registry | 存储 Skill 定义,提供加载接口,权限校验 |
|
||||
| Skill Loader | 运行时加载 Agent 对应的 Skills |
|
||||
| Skill Executor | 执行 Skill 指令,调用工具链 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Skill 定义示例
|
||||
|
||||
### 5.1 任务排序 Skill
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "任务排序",
|
||||
"description": "根据优先级、截止日期、依赖关系对任务列表进行智能排序",
|
||||
"instructions": "你是一个任务排序专家。接收任务列表后,按以下规则排序:\n1. 紧急且重要优先\n2. 有截止日期的优先\n3. 依赖其他任务的优先\n输出排序后的任务列表及理由。",
|
||||
"agent_type": "planner",
|
||||
"tools": ["get_tasks"],
|
||||
"required_context": ["原始任务列表"]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 知识检索 Skill
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "知识检索",
|
||||
"description": "从用户知识库中检索相关内容,支持向量检索和关键词检索",
|
||||
"instructions": "你是一个知识管理员。从知识库中检索与用户问题相关的内容,返回相关文档片段和来源。",
|
||||
"agent_type": "librarian",
|
||||
"tools": ["search_knowledge", "hybrid_search"],
|
||||
"required_context": ["用户查询"]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 数据分析 Skill
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "效率分析",
|
||||
"description": "分析任务完成情况,计算工作效率指标",
|
||||
"instructions": "你是一个数据分析师。接收任务列表后,分析:\n1. 完成率\n2. 平均完成时间\n3. 阻塞原因\n4. 改进建议",
|
||||
"agent_type": "analyst",
|
||||
"tools": ["get_tasks", "get_stats"],
|
||||
"required_context": ["任务数据"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API 设计
|
||||
|
||||
### 6.1 Skill 管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|-----|------|-----|
|
||||
| POST | /api/skills | 创建 Skill |
|
||||
| GET | /api/skills | 列表(支持过滤 agent_type, visibility) |
|
||||
| GET | /api/skills/{id} | 详情 |
|
||||
| PUT | /api/skills/{id} | 更新 |
|
||||
| DELETE | /api/skills/{id} | 删除 |
|
||||
|
||||
### 6.2 Skill 执行
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|-----|------|-----|
|
||||
| POST | /api/skills/{id}/execute | 手动执行 Skill |
|
||||
| GET | /api/skills/execute/{execution_id} | 查询执行结果 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据库表设计
|
||||
|
||||
### 7.1 skill 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE skill (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
instructions TEXT NOT NULL,
|
||||
agent_type VARCHAR(50) NOT NULL,
|
||||
tools JSONB DEFAULT '[]',
|
||||
required_context JSONB DEFAULT '[]',
|
||||
output_format TEXT,
|
||||
visibility VARCHAR(20) DEFAULT 'private',
|
||||
team_id UUID,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
owner_id UUID NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_skill_agent_type ON skill(agent_type);
|
||||
CREATE INDEX idx_skill_visibility ON skill(visibility);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端界面
|
||||
|
||||
### 8.1 Skill 管理入口
|
||||
|
||||
入口位置:智能链路 → Skill 市场
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 智能链路 → Skill 市场 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [我的 Skills] [团队共享] [市场] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ 任务排序 │ │
|
||||
│ │ 适用: Planner Agent │ │
|
||||
│ │ 工具: get_tasks │ │
|
||||
│ │ 描述: 根据优先级排序任务列表 │ │
|
||||
│ │ 可见: 私有 │ │
|
||||
│ │ [编辑] [禁用] [复制] │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 Skill 编辑界面
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 创建/编辑 Skill │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 名称: [________________] │
|
||||
│ 描述: [________________] │
|
||||
│ │
|
||||
│ 适用 Agent: │
|
||||
│ ( ) Master (●) Planner ( ) Executor │
|
||||
│ ( ) Librarian ( ) Analyst │
|
||||
│ │
|
||||
│ 引用工具: │
|
||||
│ ☑ get_tasks ☑ create_task │
|
||||
│ ☐ search_knowledge ☐ hybrid_search │
|
||||
│ │
|
||||
│ 指令模板: │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 你是一个任务排序专家... │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 可见性: │
|
||||
│ (●) 私有 ( ) 团队共享 ( ) 公开市场 │
|
||||
│ │
|
||||
│ [取消] [保存] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 与现有系统集成
|
||||
|
||||
| 现有组件 | 集成方式 |
|
||||
|---------|---------|
|
||||
| Agent Role | Skill.agent_type 引用现有 AgentRole 枚举 |
|
||||
| Tools | Skill.tools 引用现有 ALL_TOOLS 中的工具名 |
|
||||
| Prompts | Skill.instructions 作为 Agent 系统提示的补充 |
|
||||
| User/Team | 复用现有权限体系,visibility 字段控制 |
|
||||
| Router | Master Agent 路由逻辑不变,Skill 由子 Agent 按需调用 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 实现计划
|
||||
|
||||
### Phase 1: 基础框架
|
||||
- [ ] Skill 数据模型与 CRUD API
|
||||
- [ ] Skill Registry 服务
|
||||
- [ ] Skill 加载机制(Agent 初始化时注入)
|
||||
- [ ] 前端 Skill 管理界面
|
||||
|
||||
### Phase 2: 执行机制
|
||||
- [ ] Skill Executor
|
||||
- [ ] 工具调用桥接
|
||||
- [ ] 执行结果返回
|
||||
|
||||
### Phase 3: 高级特性
|
||||
- [ ] 团队共享机制
|
||||
- [ ] Skill 市场
|
||||
- [ ] Skill 编排(多个 Skill 串联)
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险与约束
|
||||
|
||||
1. **LLM 自主性**:依赖 LLM 准确理解 Skill 描述,需优化 prompt
|
||||
2. **工具兼容性**:Skill 引用的工具需在 ALL_TOOLS 中存在
|
||||
3. **权限控制**:团队共享需防止越权访问
|
||||
@@ -1,561 +0,0 @@
|
||||
# Schedule Planner Agent Redesign
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current planner role with a schedule-focused planning system that analyzes conversation history, the task board, and forum signals to produce actionable scheduling recommendations for the user.
|
||||
|
||||
## Scope
|
||||
|
||||
This redesign covers both the main planner role and its subagents across backend orchestration, prompts, routing, scheduled execution, todo generation, frontend presentation, and related tests.
|
||||
|
||||
## User-Approved Direction
|
||||
|
||||
- Replace the current path-planning semantics with schedule-planning semantics.
|
||||
- Redesign both the main planner role and its subagents.
|
||||
- Inputs for planning:
|
||||
- conversation history
|
||||
- task board
|
||||
- forum information
|
||||
- Output style:
|
||||
- conclusion first
|
||||
- executable schedule next
|
||||
- Trigger modes:
|
||||
- when the user explicitly asks for scheduling advice
|
||||
- at a fixed daily time
|
||||
- Daily scheduled analysis should write actionable suggestions into todo items.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Main Role
|
||||
|
||||
The current `planner` role will be replaced at the system level by a new role id:
|
||||
|
||||
- `schedule_planner`
|
||||
|
||||
Its responsibility is no longer “find the shortest execution path for a goal.” Instead, it becomes the scheduling brain that:
|
||||
|
||||
1. understands current commitments and pressure signals
|
||||
2. evaluates urgency, importance, dependency, and timing
|
||||
3. recommends near-term scheduling actions
|
||||
4. converts useful scheduled guidance into concrete todo items when triggered by the daily scheduler
|
||||
|
||||
### Subagents
|
||||
|
||||
The existing planner subagent structure will be redesigned into two schedule-specific subagents:
|
||||
|
||||
- `schedule_analysis`
|
||||
- analyzes conversation history, task board state, and forum signals
|
||||
- identifies priorities, pressure points, conflicts, dependencies, risks, and things that can be delayed
|
||||
|
||||
- `schedule_planning`
|
||||
- converts analysis into an execution-oriented schedule recommendation
|
||||
- outputs conclusion first, then a practical schedule proposal
|
||||
- when running from the daily scheduled workflow, produces todo-ready action items
|
||||
|
||||
### Trigger Paths
|
||||
|
||||
#### Interactive Trigger
|
||||
|
||||
When the user asks questions such as:
|
||||
|
||||
- what should I do today
|
||||
- how should I arrange this week
|
||||
- based on my recent work, what should I focus on next
|
||||
- help me schedule upcoming work
|
||||
|
||||
The master agent should route to `schedule_planner`.
|
||||
|
||||
The expected response shape:
|
||||
|
||||
1. current conclusion
|
||||
2. today / near-term schedule recommendation
|
||||
3. next actions
|
||||
|
||||
#### Daily Scheduled Trigger
|
||||
|
||||
A daily scheduled job invokes the schedule planner flow automatically.
|
||||
|
||||
The daily run should:
|
||||
|
||||
1. collect relevant context from conversation history, tasks, and forum data
|
||||
2. run `schedule_analysis`
|
||||
3. run `schedule_planning`
|
||||
4. convert only actionable, non-duplicate recommendations into todo items
|
||||
|
||||
The daily run should not dump raw analysis into todos. Only concise, action-worthy, user-meaningful recommendations become todos.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Inputs
|
||||
|
||||
The schedule planning system should read from three sources:
|
||||
|
||||
1. **Conversation history**
|
||||
- recent user intent
|
||||
- commitments implied in prior discussion
|
||||
- stated priorities, urgency, and unresolved threads
|
||||
|
||||
2. **Task board**
|
||||
- open items
|
||||
- current statuses
|
||||
- stalled work
|
||||
- high-priority or overdue work
|
||||
|
||||
3. **Forum information**
|
||||
- new items requiring attention
|
||||
- external pressure or discussion signals
|
||||
- updates that may change priority
|
||||
|
||||
### Internal Processing
|
||||
|
||||
The main flow should be:
|
||||
|
||||
- Master decides scheduling intent
|
||||
- `schedule_planner` receives context
|
||||
- `schedule_analysis` identifies priority structure
|
||||
- `schedule_planning` produces human-usable output
|
||||
- scheduled mode additionally writes selected suggestions into todos
|
||||
|
||||
### Outputs
|
||||
|
||||
#### Interactive Output
|
||||
|
||||
The default answer structure should be:
|
||||
|
||||
- conclusion first
|
||||
- suggested schedule second
|
||||
- next actions last
|
||||
|
||||
#### Scheduled Output
|
||||
|
||||
The scheduled run should create todo entries with:
|
||||
|
||||
- concise action phrasing
|
||||
- enough context to be actionable
|
||||
- source attribution where useful (conversation/task/forum)
|
||||
- duplicate avoidance
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
This redesign uses a two-phase migration to avoid breaking stored state and UI rendering.
|
||||
|
||||
### Phase 1: Compatibility Window
|
||||
|
||||
- accept legacy `planner` values from stored traces, mock payloads, and historical records
|
||||
- normalize legacy `planner` to `schedule_planner` at read boundaries where practical
|
||||
- accept legacy `planner_scope` and `planner_steps` as read-only legacy values and normalize them to `schedule_analysis` and `schedule_planning`
|
||||
- write only the new ids going forward:
|
||||
- `schedule_planner`
|
||||
- `schedule_analysis`
|
||||
- `schedule_planning`
|
||||
|
||||
### Phase 2: Legacy Removal
|
||||
|
||||
After the migration is complete and all active UI payloads, mock data, and tests are updated:
|
||||
|
||||
- remove legacy id acceptance from orchestration and frontend display logic
|
||||
- remove legacy mock fixtures
|
||||
- keep migration code out of prompts and core scheduling behavior
|
||||
|
||||
### Migration Scope
|
||||
|
||||
The migration must cover:
|
||||
|
||||
- backend enums and routing
|
||||
- frontend agent ids and telemetry labels
|
||||
- stored trace rendering paths
|
||||
- mock data used by agent dashboards and chat orchestration views
|
||||
- tests that still refer to `planner`, `planner_scope`, or `planner_steps`
|
||||
|
||||
## Input Contracts
|
||||
|
||||
The schedule planning system reads from three sources with explicit limits.
|
||||
|
||||
### Conversation History Contract
|
||||
|
||||
- use recent conversation history from the current user context
|
||||
- default retrieval window: last 7 days of relevant conversation turns, capped at the latest 50 turns
|
||||
- prefer turns that include commitments, priorities, deadlines, blockers, or future-oriented intent
|
||||
- if conversation history is unavailable, continue with degraded confidence
|
||||
|
||||
### Task Board Contract
|
||||
|
||||
- include open, in-progress, blocked, overdue, and high-priority tasks
|
||||
- exclude completed and archived items by default
|
||||
- include enough task metadata to reason about urgency and dependency:
|
||||
- title
|
||||
- status
|
||||
- priority
|
||||
- due date if present
|
||||
- last updated time if present
|
||||
- if task data is unavailable, continue with degraded confidence
|
||||
|
||||
### Forum Information Contract
|
||||
|
||||
- include recent forum items that may affect user priorities
|
||||
- default retrieval window: last 7 days of relevant forum signals
|
||||
- forum signals may include:
|
||||
- new posts requiring attention
|
||||
- replies or escalations
|
||||
- updates that change urgency or expected follow-up
|
||||
- if forum data is unavailable, continue with degraded confidence
|
||||
|
||||
## Output Contracts
|
||||
|
||||
### `schedule_analysis` Output Schema
|
||||
|
||||
The analysis stage should produce a structured summary with these fields:
|
||||
|
||||
- `top_priorities`: list of current highest-priority focus areas
|
||||
- `risks`: list of risk or pressure signals
|
||||
- `conflicts`: list of timing or dependency conflicts
|
||||
- `deferrable_items`: list of lower-priority items that can be delayed
|
||||
- `evidence`: source references grouped by `conversation`, `task_board`, or `forum`
|
||||
- `confidence`: one of `high`, `medium`, `low`
|
||||
|
||||
### `schedule_planning` Output Schema
|
||||
|
||||
The planning stage should produce a structured recommendation with these fields:
|
||||
|
||||
- `conclusion`: short decision-oriented summary
|
||||
- `today_plan`: list of suggested actions for the current day or immediate next window
|
||||
- `near_term_plan`: list of actions for the next few days or current week
|
||||
- `next_actions`: short ordered action list
|
||||
- `todo_candidates`: only present in scheduled mode; candidate todo items derived from the recommendation
|
||||
- `confidence`: one of `high`, `medium`, `low`
|
||||
|
||||
### `todo_candidates` Schema
|
||||
|
||||
Each `todo_candidate` must use this structure:
|
||||
|
||||
- `title`: required short action text
|
||||
- `description`: required short rationale grounded in source context
|
||||
- `sources`: required list of provenance objects
|
||||
- `priority`: optional normalized priority such as `high`, `medium`, `low`
|
||||
- `target_window`: optional string such as `today` or `this_week`
|
||||
|
||||
Each provenance object in `sources` must contain:
|
||||
|
||||
- `type`: one of `conversation`, `task_board`, `forum`
|
||||
- `id`: source object id when available, otherwise a stable synthetic reference
|
||||
- `label`: short human-readable source label
|
||||
|
||||
### Evidence Structure
|
||||
|
||||
Each item in `schedule_analysis.evidence` must contain:
|
||||
|
||||
- `type`: one of `conversation`, `task_board`, `forum`
|
||||
- `id`: source object id when available, otherwise a stable synthetic reference
|
||||
- `label`: short human-readable identifier
|
||||
- `reason`: brief explanation of why the signal matters to scheduling
|
||||
|
||||
### Interactive Response Contract
|
||||
|
||||
The user-facing answer should always follow this shape:
|
||||
|
||||
1. conclusion
|
||||
2. suggested schedule
|
||||
3. next actions
|
||||
|
||||
If confidence is low, the response must say that explicitly and avoid overconfident scheduling language.
|
||||
|
||||
## Daily Scheduler Contract
|
||||
|
||||
The daily scheduled trigger must follow explicit execution semantics.
|
||||
|
||||
### Execution Model
|
||||
|
||||
- run once per user per local date
|
||||
- default execution time: 07:00 in the user's configured timezone
|
||||
- if the user has no configured timezone, skip the run and log the skip reason
|
||||
- do not automatically backfill missed runs
|
||||
- enforce idempotency by `(user_id, local_date, job_type)` so the same daily analysis is not executed more than once successfully
|
||||
|
||||
### Scheduled Mode Behavior
|
||||
|
||||
A successful scheduled run should:
|
||||
|
||||
1. gather available context from the three input sources
|
||||
2. execute `schedule_analysis`
|
||||
3. execute `schedule_planning`
|
||||
4. create todo items from selected `todo_candidates`
|
||||
5. store run telemetry and outcome metadata
|
||||
|
||||
If one or more sources are missing, continue when there is still enough evidence to produce a useful recommendation and mark confidence as reduced.
|
||||
|
||||
Signal evaluation rules:
|
||||
|
||||
- a **strong source** is a source with enough current evidence to support prioritization on its own, such as multiple open high-priority tasks or a recent forum escalation
|
||||
- a **meaningful signal** is a discrete scheduling-relevant item extracted from any source, such as an overdue task, a stated commitment in conversation history, or a forum escalation
|
||||
- the planner may still run with one strong source
|
||||
- scheduled mode may create todos only when at least two meaningful signals exist across all inputs
|
||||
|
||||
If fewer than two meaningful signals are available across all sources, the scheduler should not create todos and should log a low-context outcome.
|
||||
|
||||
Delayed execution rule:
|
||||
|
||||
- if the 07:00 run is delayed by temporary outage or worker unavailability, the system may still execute one delayed run later on the same user-local date
|
||||
- if the entire local date passes without a successful run, do not backfill on the next day
|
||||
|
||||
## Todo Creation Rules
|
||||
|
||||
Todo creation is the main scheduled side effect and must be tightly constrained.
|
||||
|
||||
### Creation Rules
|
||||
|
||||
- create at most 3 todo items per daily run
|
||||
- only create todos for actions that are concrete, near-term, and user-actionable
|
||||
- do not create todos for vague advice, reflections, or duplicated reminders
|
||||
- store source provenance when available:
|
||||
- `conversation`
|
||||
- `task_board`
|
||||
- `forum`
|
||||
|
||||
### Duplicate Detection
|
||||
|
||||
A candidate todo is considered a duplicate if there is already an open todo that matches all of the following:
|
||||
|
||||
- same normalized action text
|
||||
- same source category or same source object when available
|
||||
- created within the last 7 days
|
||||
|
||||
Normalization rules for action text:
|
||||
|
||||
- trim surrounding whitespace
|
||||
- collapse repeated internal whitespace to a single space
|
||||
- lowercase Latin characters
|
||||
- remove trailing full stop / period punctuation only
|
||||
|
||||
Source comparison rules:
|
||||
|
||||
- if a provenance object includes a stable source `id`, compare by `(type, id)`
|
||||
- if no stable source id exists, compare by `(type, normalized label)`
|
||||
- if multiple sources support one recommendation, compare against the highest-priority provenance in this order: `task_board`, `forum`, `conversation`
|
||||
|
||||
When a duplicate is detected:
|
||||
|
||||
- do not create a new todo
|
||||
- record the skip reason in scheduler telemetry
|
||||
|
||||
### Todo Fields
|
||||
|
||||
Scheduled-created todos should include at minimum these persisted fields:
|
||||
|
||||
- `title`: required
|
||||
- `description`: required
|
||||
- `source_type`: required primary provenance type
|
||||
- `source_id`: optional stable source id
|
||||
- `source_label`: required fallback human-readable provenance label
|
||||
- `created_by`: required and set to `schedule_planner`
|
||||
- `created_at`: required timestamp
|
||||
- `priority`: optional normalized priority
|
||||
- `target_window`: optional normalized scheduling window
|
||||
|
||||
## Routing Boundaries
|
||||
|
||||
The system must distinguish scheduling from adjacent planning behaviors.
|
||||
|
||||
### Route to `schedule_planner` when the user asks for:
|
||||
|
||||
- today or this week planning
|
||||
- what to focus on next
|
||||
- priority ordering across ongoing work
|
||||
- time-aware sequencing of current commitments
|
||||
|
||||
### Do not route to `schedule_planner` when the user asks for:
|
||||
|
||||
- deep implementation planning for a feature
|
||||
- code execution or task fulfillment
|
||||
- research-only retrieval
|
||||
- pure analysis without scheduling intent
|
||||
|
||||
In ambiguous cases such as "what should I do next?", prefer `schedule_planner` when the available context includes multiple active tasks, recent commitments, or forum pressure signals.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### Role and Graph Layer
|
||||
|
||||
Update the orchestration layer so the planner role is redefined as `schedule_planner` rather than `planner`.
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `backend/app/agents/state.py`
|
||||
- `backend/app/agents/graph.py`
|
||||
- `backend/app/agents/prompts.py`
|
||||
- `backend/app/routers/agent.py`
|
||||
- `backend/app/services/agent_service.py`
|
||||
|
||||
Required changes:
|
||||
|
||||
- rename role ids where appropriate
|
||||
- update graph node registration
|
||||
- update master routing rules
|
||||
- replace planner subagent mappings
|
||||
- update telemetry and sub-commander trace labels
|
||||
|
||||
### Prompt Layer
|
||||
|
||||
Replace the current planner prompt family with schedule-specific instructions.
|
||||
|
||||
Needed prompt families:
|
||||
|
||||
- `SCHEDULE_PLANNER_SYSTEM_PROMPT`
|
||||
- `SCHEDULE_ANALYSIS_PROMPT`
|
||||
- `SCHEDULE_PLANNING_PROMPT`
|
||||
|
||||
Prompt requirements:
|
||||
|
||||
- reason over conversation history, tasks, and forum state
|
||||
- prioritize urgency, importance, and dependency
|
||||
- avoid abstract productivity advice
|
||||
- produce concrete, immediate scheduling output
|
||||
- in scheduled mode, generate todo-worthy suggestions only
|
||||
|
||||
### Scheduled Execution Layer
|
||||
|
||||
Add or update the daily scheduled workflow so it can call the schedule planner flow automatically.
|
||||
|
||||
Likely touchpoints:
|
||||
|
||||
- scheduler service
|
||||
- existing daily planning jobs
|
||||
- todo creation services
|
||||
|
||||
Required behavior:
|
||||
|
||||
- fixed daily execution time
|
||||
- fetch relevant context
|
||||
- call schedule planner pipeline
|
||||
- write selected recommendations into todos
|
||||
- skip duplicate todo creation
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
Frontend needs to reflect the new role system consistently.
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `frontend/src/data/agents.ts`
|
||||
- `frontend/src/pages/agents/index.vue`
|
||||
- `frontend/src/components/chat/OrchestrationPanel.vue`
|
||||
- `frontend/src/pages/chat/composables/useChatView.ts`
|
||||
- related frontend tests
|
||||
|
||||
Required updates:
|
||||
|
||||
- replace planner display labels with schedule planner labels
|
||||
- rename planner subagents to schedule analysis / schedule planning
|
||||
- update orchestration telemetry labels
|
||||
- update example mock state and tests
|
||||
- use these exact frontend ids:
|
||||
- `schedule_planner`
|
||||
- `schedule_analysis`
|
||||
- `schedule_planning`
|
||||
- use these exact default Chinese labels:
|
||||
- `日程规划师`
|
||||
- `日程分析员`
|
||||
- `日程编排员`
|
||||
- update active route visualization and commander skill labels to the new ids
|
||||
|
||||
## Naming
|
||||
|
||||
### Main Agent
|
||||
|
||||
- old: `planner`
|
||||
- new: `schedule_planner`
|
||||
- display role: `日程规划师`
|
||||
|
||||
### Subagents
|
||||
|
||||
- old: `planner_scope`
|
||||
- new: `schedule_analysis`
|
||||
- display role: `日程分析员`
|
||||
|
||||
- old: `planner_steps`
|
||||
- new: `schedule_planning`
|
||||
- display role: `日程编排员`
|
||||
|
||||
## Constraints
|
||||
|
||||
- do not keep dual role names for long-term compatibility unless a specific dependency forces it
|
||||
- do not create todos for every suggestion
|
||||
- do not turn the planner into a generic life coach
|
||||
- keep scheduling grounded in current project signals
|
||||
- preserve the existing agent architecture where possible, while fully changing planner semantics
|
||||
|
||||
## Observability
|
||||
|
||||
The redesign must emit enough telemetry to debug routing and scheduled execution.
|
||||
|
||||
Required telemetry fields:
|
||||
|
||||
- selected main route
|
||||
- selected subagent
|
||||
- available input sources
|
||||
- missing input sources
|
||||
- run mode: `interactive` or `scheduled`
|
||||
- confidence level
|
||||
- todos created count
|
||||
- todos skipped as duplicates count
|
||||
- scheduler run success / skipped / failed
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Backend Acceptance Criteria
|
||||
|
||||
- a scheduling-intent user query routes to `schedule_planner`
|
||||
- `schedule_analysis` and `schedule_planning` are both reachable through the orchestration layer
|
||||
- legacy planner ids are normalized during the compatibility window
|
||||
- daily scheduled runs do not execute more than once per user per local date
|
||||
- low-context daily runs do not create todos
|
||||
- duplicate todo candidates are skipped instead of recreated
|
||||
|
||||
### Frontend Acceptance Criteria
|
||||
|
||||
- the agents page displays `日程规划师` instead of the previous planner label
|
||||
- the planner subagent chips display `日程分析员` and `日程编排员`
|
||||
- orchestration mock data and route highlights use the new ids
|
||||
- tests no longer depend on `planner_scope` or `planner_steps` after migration is complete
|
||||
|
||||
### Failure and Fallback Criteria
|
||||
|
||||
- if forum data is missing, the planner still runs with degraded confidence
|
||||
- if task board data is missing, the planner still runs with degraded confidence when other strong context exists
|
||||
- if fewer than two meaningful signals are available, scheduled mode creates no todos
|
||||
- if the user has no timezone configured, the daily scheduled run is skipped and logged
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Backend
|
||||
|
||||
Add or update tests for:
|
||||
|
||||
- master routing to `schedule_planner`
|
||||
- schedule subagent selection behavior
|
||||
- prompt invariants for schedule-focused output
|
||||
- scheduled daily run creates todos from actionable suggestions
|
||||
- duplicate todo protection
|
||||
|
||||
### Frontend
|
||||
|
||||
Add or update tests for:
|
||||
|
||||
- renamed main role and subagent labels
|
||||
- orchestration panel route display
|
||||
- active subagent telemetry
|
||||
- mock agent graph data using `schedule_planner`, `schedule_analysis`, and `schedule_planning`
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Broad rename surface**
|
||||
- `planner` is referenced across backend and frontend, so a full rename must be systematic
|
||||
|
||||
2. **Scheduled todo spam**
|
||||
- daily runs may create low-value or duplicate todos unless filtered carefully
|
||||
|
||||
3. **Prompt drift**
|
||||
- if prompts stay too abstract, the new agent will sound renamed but not actually scheduling-oriented
|
||||
|
||||
## Recommendation
|
||||
|
||||
Implement this as a real role-system redesign, not as a display-only rename. The role id, subagent ids, prompt family, routing logic, and frontend telemetry should all align on the new scheduling semantics so the system remains internally coherent.
|
||||
Reference in New Issue
Block a user