Add project documentation and specs
This commit is contained in:
@@ -0,0 +1,711 @@
|
||||
# Chat Enhancement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为沟通系统添加文件上传(AI理解内容)和表情包选择器功能
|
||||
|
||||
**Architecture:** 前端在 ChatView 输入区添加附件/Emoji按钮,后端扩展 ChatRequest 支持 file_ids,AgentService 读取文件内容作为上下文
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript + FastAPI + SQLAlchemy + ChromaDB
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── views/
|
||||
│ └── ChatView.vue # 修改 - 添加附件/Emoji按钮
|
||||
├── components/
|
||||
│ └── chat/
|
||||
│ ├── EmojiPicker.vue # 新建 - Emoji选择器
|
||||
│ └── FileMessage.vue # 新建 - 文件消息气泡
|
||||
└── api/
|
||||
├── conversation.ts # 修改 - chat支持file_ids
|
||||
└── document.ts # 新增 - getDocumentContent
|
||||
|
||||
backend/app/
|
||||
├── routers/
|
||||
│ ├── conversation.py # 修改 - ChatRequest支持file_ids
|
||||
│ └── document.py # 修改 - 新增content接口
|
||||
├── services/
|
||||
│ └── agent_service.py # 修改 - chat支持文件上下文
|
||||
└── models/
|
||||
└── conversation.py # 修改 - Message新增attachments字段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 EmojiPicker 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/chat/EmojiPicker.vue`
|
||||
|
||||
- [ ] **Step 1: 创建 EmojiPicker.vue**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [emoji: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const categories = [
|
||||
{ key: 'smile', name: '😀', label: '笑脸' },
|
||||
{ key: 'gesture', name: '👍', label: '手势' },
|
||||
{ key: 'object', name: '📦', label: '物品' },
|
||||
{ key: 'symbol', name: '💬', label: '符号' },
|
||||
]
|
||||
|
||||
const emojiData: Record<string, string[]> = {
|
||||
smile: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌'],
|
||||
gesture: ['👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🖖', '🤙', '💪', '🙏', '👏'],
|
||||
object: ['📄', '📁', '🖼️', '📊', '📝', '💾', '📧', '🔗', '📌', '🔍', '💡', '⚡'],
|
||||
symbol: ['✅', '❌', '⚠️', '🔥', '💯', '🎯', '⭐', '✨', '💬', '🗨️', '❤️', '🧡'],
|
||||
}
|
||||
|
||||
const activeCategory = ref('smile')
|
||||
|
||||
function selectEmoji(emoji: string) {
|
||||
emit('select', emoji)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="emoji-picker">
|
||||
<div class="emoji-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
:class="{ active: activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
:title="cat.label"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="emoji in emojiData[activeCategory]"
|
||||
:key="emoji"
|
||||
class="emoji-btn"
|
||||
@click="selectEmoji(emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.emoji-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.emoji-tabs button {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-tabs button:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.emoji-tabs button.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-btn:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建 FileMessage 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/chat/FileMessage.vue`
|
||||
|
||||
- [ ] **Step 1: 创建 FileMessage.vue**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FileText, Image, File } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
filename: string
|
||||
fileType: string
|
||||
fileSize: number
|
||||
}>()
|
||||
|
||||
const icon = computed(() => {
|
||||
if (props.fileType.startsWith('image/')) return Image
|
||||
if (props.fileType.includes('pdf') || props.fileType.includes('document')) return FileText
|
||||
return File
|
||||
})
|
||||
|
||||
const fileSizeDisplay = computed(() => {
|
||||
const size = props.fileSize
|
||||
if (size < 1024) return size + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
|
||||
return (size / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
const ext = computed(() => {
|
||||
const parts = props.filename.split('.')
|
||||
return parts.length > 1 ? parts.pop()?.toUpperCase() : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-message">
|
||||
<div class="file-icon">
|
||||
<component :is="icon" :size="24" />
|
||||
<span v-if="ext" class="file-ext">{{ ext }}</span>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ filename }}</span>
|
||||
<span class="file-size">{{ fileSizeDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
position: relative;
|
||||
color: var(--accent-cyan);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-ext {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -4px;
|
||||
font-size: 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--bg-void);
|
||||
background: var(--accent-cyan);
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 修改前端 API - conversation.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/conversation.ts`
|
||||
|
||||
- [ ] **Step 1: 修改 conversation.ts**
|
||||
|
||||
```typescript
|
||||
// 在 chat 方法中添加 file_ids 参数
|
||||
chat(message: string, conversationId?: string, fileIds: string[] = []) {
|
||||
return api.post('/api/conversations/chat', {
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
file_ids: fileIds,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 修改前端 API - document.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/document.ts`
|
||||
|
||||
- [ ] **Step 1: 添加 getContent 方法**
|
||||
|
||||
```typescript
|
||||
// 新增方法
|
||||
getContent(id: string) {
|
||||
return api.get<string>(`/api/documents/${id}/content`)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 修改 ChatView.vue - 添加按钮和状态
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/ChatView.vue`
|
||||
|
||||
- [ ] **Step 1: 在 script setup 中添加以下内容**
|
||||
|
||||
在 import 后添加:
|
||||
|
||||
```typescript
|
||||
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
|
||||
import FileMessage from '@/components/chat/FileMessage.vue'
|
||||
import { Paperclip, Smile, Download } from 'lucide-vue-next'
|
||||
|
||||
// 新增状态
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const showEmojiPicker = ref(false)
|
||||
const selectedFiles = ref<{ id: string; name: string; type: string; size: number }[]>([])
|
||||
const uploadingFiles = ref<{ name: string; progress: number }[]>([])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加文件上传方法**
|
||||
|
||||
```typescript
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files?.length) return
|
||||
|
||||
for (const file of input.files) {
|
||||
// 校验大小
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert(`文件 ${file.name} 超过10MB限制`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 显示上传中状态
|
||||
uploadingFiles.value.push({ name: file.name, progress: 0 })
|
||||
|
||||
try {
|
||||
const response = await documentApi.upload(file)
|
||||
selectedFiles.value.push({
|
||||
id: response.data.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('上传失败:', e)
|
||||
alert(`文件 ${file.name} 上传失败`)
|
||||
} finally {
|
||||
uploadingFiles.value = uploadingFiles.value.filter(f => f.name !== file.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空 input
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function insertEmoji(emoji: string) {
|
||||
inputMessage.value += emoji
|
||||
showEmojiPicker.value = false
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 sendMessage 中处理文件上传**
|
||||
|
||||
修改 sendMessage 函数,在发送消息时附带 file_ids:
|
||||
|
||||
```typescript
|
||||
async function sendMessage() {
|
||||
if (!inputMessage.value.trim() || isSending.value) return
|
||||
|
||||
// 如果有文件,先上传
|
||||
if (selectedFiles.value.length > 0) {
|
||||
// file_ids 已经在 selectedFiles 中
|
||||
}
|
||||
|
||||
isSending.value = true
|
||||
isTyping.value = true
|
||||
const text = inputMessage.value.trim()
|
||||
const fileIds = selectedFiles.value.map(f => f.id)
|
||||
|
||||
// 添加用户消息(带文件)
|
||||
store.addMessage({
|
||||
id: `temp-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
attachments: selectedFiles.value,
|
||||
})
|
||||
|
||||
inputMessage.value = ''
|
||||
selectedFiles.value = [] // 清空已选文件
|
||||
|
||||
// ... 后续发送逻辑,传入 fileIds
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 template 中添加附件和 Emoji 按钮**
|
||||
|
||||
在输入框的按钮区域添加:
|
||||
|
||||
```vue
|
||||
<!-- 文件选择 input(隐藏) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 附件按钮 -->
|
||||
<button class="attach-btn" @click="openFilePicker" title="上传文件">
|
||||
<Paperclip :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- Emoji 按钮 -->
|
||||
<div class="emoji-wrapper">
|
||||
<button
|
||||
class="emoji-btn"
|
||||
:class="{ active: showEmojiPicker }"
|
||||
@click="showEmojiPicker = !showEmojiPicker"
|
||||
title="表情包"
|
||||
>
|
||||
<Smile :size="15" />
|
||||
</button>
|
||||
<EmojiPicker
|
||||
:visible="showEmojiPicker"
|
||||
@select="insertEmoji"
|
||||
@close="showEmojiPicker = false"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 添加样式**
|
||||
|
||||
在 style 部分添加:
|
||||
|
||||
```css
|
||||
/* 文件消息样式 */
|
||||
.file-msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.file-msg-row .msg-avatar {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 附件按钮 */
|
||||
.attach-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.attach-btn:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Emoji 按钮 */
|
||||
.emoji-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-btn:hover,
|
||||
.emoji-btn.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 修改后端 - ChatRequest 支持 file_ids
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/conversation.py`
|
||||
|
||||
- [ ] **Step 1: 修改 ChatRequest**
|
||||
|
||||
```python
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
conversation_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
file_ids: list[str] = [] # 新增
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 修改后端 - Message 新增 attachments 字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/conversation.py`
|
||||
|
||||
- [ ] **Step 1: 修改 Message 模型**
|
||||
|
||||
```python
|
||||
class Message(BaseModel):
|
||||
__tablename__ = "messages"
|
||||
|
||||
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False, index=True)
|
||||
role = Column(String(20), nullable=False) # user, assistant, system
|
||||
content = Column(Text, nullable=False)
|
||||
model = Column(String(100), nullable=True)
|
||||
tokens_used = Column(Integer, nullable=True)
|
||||
attachments = Column(JSON, nullable=True) # 新增: [{file_id, filename, file_type, file_size}]
|
||||
|
||||
conversation = relationship("Conversation", back_populates="messages")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 修改后端 - 新增 document content 接口
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/document.py`
|
||||
|
||||
- [ ] **Step 1: 添加 content 接口**
|
||||
|
||||
```python
|
||||
@router.get("/{document_id}/content")
|
||||
async def get_document_content(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取文档的文本内容(用于AI理解)"""
|
||||
from app.services.document_service import DocumentService
|
||||
|
||||
doc_svc = DocumentService(db)
|
||||
content = await doc_svc.get_document_content(current_user.id, document_id)
|
||||
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="文档不存在或无内容")
|
||||
|
||||
return {"content": content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 修改后端 - DocumentService 新增 get_document_content
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/document_service.py`
|
||||
|
||||
- [ ] **Step 1: 添加 get_document_content 方法**
|
||||
|
||||
```python
|
||||
async def get_document_content(self, user_id: str, document_id: str) -> str | None:
|
||||
"""获取文档的文本内容"""
|
||||
import os
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.user_id == user_id,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
return None
|
||||
|
||||
file_path = doc.file_path
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
# 根据文件类型读取内容
|
||||
ext = doc.filename.split('.')[-1].lower()
|
||||
|
||||
try:
|
||||
if ext == 'txt':
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
elif ext == 'md':
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
elif ext == 'pdf':
|
||||
# 简单文本提取(生产环境应使用专业库)
|
||||
# 这里可以先用 pdfplumber 或 PyPDF2
|
||||
return f"[PDF文档] {doc.filename}"
|
||||
else:
|
||||
return f"[文档] {doc.filename}"
|
||||
except Exception:
|
||||
return f"[文档] {doc.filename}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: 修改后端 - AgentService 支持文件上下文
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/agent_service.py`
|
||||
|
||||
- [ ] **Step 1: 修改 chat_simple 方法支持 file_ids**
|
||||
|
||||
在 chat_simple 方法中:
|
||||
|
||||
```python
|
||||
async def chat_simple(
|
||||
self,
|
||||
user_id: str,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
file_ids: list[str] = None,
|
||||
) -> tuple[str, str, str]:
|
||||
# ... 现有逻辑 ...
|
||||
|
||||
# 如果有文件,读取内容作为上下文
|
||||
file_context = ""
|
||||
if file_ids:
|
||||
from app.services.document_service import DocumentService
|
||||
doc_svc = DocumentService(self.db)
|
||||
|
||||
for file_id in file_ids:
|
||||
content = await doc_svc.get_document_content(user_id, file_id)
|
||||
if content:
|
||||
file_context += f"\n\n[用户上传文件内容]\n{content}\n[/文件内容]"
|
||||
|
||||
# 将文件上下文添加到消息
|
||||
full_message = f"{message}\n{file_context}" if file_context else message
|
||||
|
||||
# 调用 LLM
|
||||
response = await self.llm.chat(full_message, ...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 验证和测试
|
||||
|
||||
- [ ] **Step 1: 前端 TypeScript 检查**
|
||||
|
||||
```bash
|
||||
cd frontend && npx vue-tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 后端语法检查**
|
||||
|
||||
```bash
|
||||
cd backend && python -m py_compile app/routers/conversation.py app/services/agent_service.py app/services/document_service.py
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 启动服务测试**
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend && python -m uvicorn app.main:app --reload
|
||||
|
||||
# 前端
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行选项
|
||||
|
||||
**1. Subagent-Driven (推荐)** - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代
|
||||
|
||||
**2. Inline Execution** - 在当前会话中按批次执行任务
|
||||
|
||||
选择哪种方式?
|
||||
44
docs/superpowers/plans/2026-03-20-daily-todo-migration.md
Normal file
44
docs/superpowers/plans/2026-03-20-daily-todo-migration.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Daily Todo 数据库迁移
|
||||
|
||||
## 自动迁移
|
||||
|
||||
SQLAlchemy 会在应用启动时通过 `init_db()` 自动创建所有表,包括 `daily_todos` 表。
|
||||
|
||||
## 手动迁移(如需)
|
||||
|
||||
如果需要手动创建表,执行以下 SQL:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS daily_todos (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
is_completed BOOLEAN NOT NULL DEFAULT 0,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
source_detail VARCHAR(500),
|
||||
source_ref_id VARCHAR(36),
|
||||
todo_date VARCHAR(10) NOT NULL,
|
||||
completed_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_date ON daily_todos(user_id, todo_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_id ON daily_todos(user_id);
|
||||
```
|
||||
|
||||
## 表说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(36) | 主键,UUID |
|
||||
| user_id | VARCHAR(36) | 所属用户,索引 |
|
||||
| title | VARCHAR(500) | 待办标题 |
|
||||
| is_completed | BOOLEAN | 是否完成,默认 false |
|
||||
| source | VARCHAR(20) | 来源:ai_kanban / ai_chat / manual |
|
||||
| source_detail | VARCHAR(500) | 来源说明文本 |
|
||||
| source_ref_id | VARCHAR(36) | 来源原始ID(看板TaskID或对话ID) |
|
||||
| todo_date | VARCHAR(10) | 所属日期 YYYY-MM-DD |
|
||||
| completed_at | TIMESTAMP | 完成时间 |
|
||||
| created_at | TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 更新时间 |
|
||||
1184
docs/superpowers/plans/2026-03-20-daily-todo-plan.md
Normal file
1184
docs/superpowers/plans/2026-03-20-daily-todo-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
215
docs/superpowers/plans/2026-03-20-langsmith-integration.md
Normal file
215
docs/superpowers/plans/2026-03-20-langsmith-integration.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# LangSmith 集成实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 Jarvis 后端集成 LangSmith 追踪,实现调用追踪、对话历史管理和评估支持。
|
||||
|
||||
**Architecture:** LangGraph 的 `compile()` 方法接受全局 Callbacks 参数,会自动将 Callback 传播到所有节点的 LLM 调用。只需在 Graph 编译时注入 `LangChainTracer`,即可覆盖 Master/Planner/Executor/Librarian/Analyst 所有 5 个节点和工具调用。
|
||||
|
||||
**Tech Stack:** langsmith, langchain-core, langgraph
|
||||
|
||||
---
|
||||
|
||||
## 文件变更总览
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `backend/pyproject.toml` | 添加 langsmith 依赖 |
|
||||
| `backend/.env.example` | 新增 LangSmith 环境变量 |
|
||||
| `backend/app/config.py` | 新增 3 个配置字段 |
|
||||
| `backend/app/config_tracing.py` | 新建,callback 工厂函数 |
|
||||
| `backend/app/agents/graph.py` | 修改 `create_agent_graph()` 支持 callbacks,合并全局 callbacks |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 添加 langsmith 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/pyproject.toml`
|
||||
|
||||
- [ ] **Step 1: 添加 langsmith 依赖**
|
||||
|
||||
在 `dependencies` 数组中 `"langchain-ollama>=0.4.0",` 后添加:
|
||||
|
||||
```toml
|
||||
"langchain-ollama>=0.4.0",
|
||||
|
||||
# 可观测性
|
||||
"langsmith>=0.1.0",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 安装依赖**
|
||||
|
||||
Run: `cd backend && uv sync`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 添加环境变量模板
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/.env.example`
|
||||
|
||||
- [ ] **Step 1: 在文件末尾添加 LangSmith 配置节**
|
||||
|
||||
在 `# === 定时任务 ===` 节之前添加:
|
||||
|
||||
```env
|
||||
# === LangSmith 可观测性 ===
|
||||
# 启用 LangSmith 追踪(可选)
|
||||
LANGSMITH_TRACING=false
|
||||
LANGSMITH_API_KEY=your-langsmith-api-key
|
||||
LANGSMITH_PROJECT=jarvis-agent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Config 层添加 LangSmith 配置
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/config.py`
|
||||
- Create: `backend/app/config_tracing.py` (callback 工厂函数)
|
||||
|
||||
- [ ] **Step 1: 在 Settings 类中添加 3 个配置字段**
|
||||
|
||||
在 `# === NAS 部署 ===` 节之前添加:
|
||||
|
||||
```python
|
||||
# === LangSmith 可观测性 ===
|
||||
LANGSMITH_TRACING: bool = False
|
||||
LANGSMITH_API_KEY: str = ""
|
||||
LANGSMITH_PROJECT: str = "jarvis-agent"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 callback 工厂函数**
|
||||
|
||||
创建新文件 `backend/app/config_tracing.py`:
|
||||
|
||||
```python
|
||||
"""
|
||||
LangSmith Tracing 配置
|
||||
提供 Callback 工厂函数,用于 LangGraph 追踪
|
||||
"""
|
||||
|
||||
from langchain_core.callbacks import LangChainTracer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def get_langsmith_callbacks() -> list:
|
||||
"""
|
||||
根据配置返回 LangSmith Callback 列表
|
||||
未启用时返回空列表
|
||||
"""
|
||||
if not settings.LANGSMITH_TRACING:
|
||||
return []
|
||||
|
||||
if not settings.LANGSMITH_API_KEY:
|
||||
return []
|
||||
|
||||
return [
|
||||
LangChainTracer(
|
||||
project_name=settings.LANGSMITH_PROJECT,
|
||||
)
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 修改 Graph 接受 Callbacks
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/agents/graph.py`
|
||||
|
||||
- [ ] **Step 1: 修改 create_agent_graph() 签名**
|
||||
|
||||
将函数签名从:
|
||||
|
||||
```python
|
||||
def create_agent_graph():
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
def create_agent_graph(callbacks: list | None = None):
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 将 callbacks 传给 compile()**
|
||||
|
||||
将:
|
||||
|
||||
```python
|
||||
return graph.compile()
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
return graph.compile(callbacks=callbacks)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 get_agent_graph() 注入默认 callbacks**
|
||||
|
||||
将:
|
||||
|
||||
```python
|
||||
def get_agent_graph():
|
||||
global _agent_graph
|
||||
if _agent_graph is None:
|
||||
_agent_graph = create_agent_graph()
|
||||
return _agent_graph
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
from app.config_tracing import get_langsmith_callbacks
|
||||
|
||||
|
||||
def get_agent_graph(callbacks: list | None = None):
|
||||
"""
|
||||
获取编译好的 Agent 图(单例缓存)。
|
||||
|
||||
Callbacks 在首次编译时固定注入,后续调用忽略 callbacks 参数。
|
||||
如需变更 Callbacks(如修改 LANGCHAIN_PROJECT),需重启服务。
|
||||
|
||||
Args:
|
||||
callbacks: 可选的额外 Callbacks,会与全局 LangSmith Callbacks 合并
|
||||
"""
|
||||
global _agent_graph
|
||||
if _agent_graph is None:
|
||||
langsmith_callbacks = get_langsmith_callbacks()
|
||||
all_callbacks = (callbacks or []) + langsmith_callbacks
|
||||
_agent_graph = create_agent_graph(callbacks=all_callbacks or None)
|
||||
return _agent_graph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 验证集成
|
||||
|
||||
- [ ] **Step 1: 确认依赖安装**
|
||||
|
||||
Run: `cd backend && uv sync`
|
||||
|
||||
- [ ] **Step 2: 启动服务验证无报错**
|
||||
|
||||
Run: `cd backend && uv run uvicorn app.main:app --reload --port 8000`
|
||||
|
||||
- [ ] **Step 3: 配置 .env 并测试**
|
||||
|
||||
在 `.env` 中添加:
|
||||
|
||||
```env
|
||||
LANGSMITH_TRACING=true
|
||||
LANGSMITH_API_KEY=your-api-key
|
||||
LANGSMITH_PROJECT=jarvis-agent
|
||||
```
|
||||
|
||||
发起一次 Agent 对话,访问 https://smith.langchain.com 确认 trace 出现。
|
||||
|
||||
预期在 Dashboard 中看到:
|
||||
- 5 个节点(master/planner/executor/librarian/analyst)的执行记录
|
||||
- 每个节点的 LLM 输入/输出
|
||||
- 工具调用记录
|
||||
- Token 消耗统计
|
||||
903
docs/superpowers/plans/2026-03-20-settings-register-plan.md
Normal file
903
docs/superpowers/plans/2026-03-20-settings-register-plan.md
Normal file
@@ -0,0 +1,903 @@
|
||||
# 注册界面 + 设置界面 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现用户注册界面和设置界面,支持多用户和用户级 LLM 配置
|
||||
|
||||
**Architecture:**
|
||||
- 后端:扩展 User 模型,新建 settings router/service,前端认证依赖现有 auth 机制
|
||||
- 前端:LoginView 添加注册 Tab,新建 SettingsView 页面,复用现有 sci-fi 风格
|
||||
- 数据:User 表增加 JSON 字段存储 llm_config 和 scheduler_config
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy + Vue 3 + axios + Pinia
|
||||
|
||||
---
|
||||
|
||||
## 文件总览
|
||||
|
||||
```
|
||||
backend/
|
||||
app/
|
||||
models/
|
||||
user.py # 修改:添加 llm_config, scheduler_config 字段
|
||||
schemas/
|
||||
settings.py # 新建:Settings Pydantic schemas
|
||||
routers/
|
||||
settings.py # 新建:settings API router
|
||||
services/
|
||||
settings_service.py # 新建:设置逻辑服务
|
||||
|
||||
frontend/
|
||||
src/
|
||||
api/
|
||||
settings.ts # 新建:settings API 客户端
|
||||
views/
|
||||
LoginView.vue # 修改:添加注册 Tab
|
||||
SettingsView.vue # 新建:设置页面
|
||||
router/
|
||||
index.ts # 修改:添加 /settings 路由
|
||||
components/
|
||||
SidebarNav.vue # 修改:添加设置菜单
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 后端 - User 模型扩展
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/user.py`
|
||||
|
||||
- [ ] **Step 1: 添加 JSON 字段到 User 模型**
|
||||
|
||||
读取现有 User 模型,添加 llm_config 和 scheduler_config 字段:
|
||||
|
||||
```python
|
||||
# 在 User 模型类中添加
|
||||
from sqlalchemy import JSON
|
||||
|
||||
llm_config = Column(JSON, nullable=True) # 用户 LLM 配置
|
||||
scheduler_config = Column(JSON, nullable=True) # 定时任务配置
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 设置默认值**
|
||||
|
||||
确保新用户创建时有默认配置(在 User 模型或 service 层处理)
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/user.py
|
||||
git commit -m "feat(settings): add llm_config and scheduler_config fields to User model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 后端 - Settings Schema 定义
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/settings.py`
|
||||
|
||||
- [ ] **Step 1: 创建 settings schemas**
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
# LLM Provider 类型
|
||||
LLMProviderType = Literal["openai", "claude", "ollama", "deepseek", "custom"]
|
||||
LLMType = Literal["chat", "vlm", "embedding", "rerank"]
|
||||
|
||||
# 单个模型配置
|
||||
class LLMModelConfig(BaseModel):
|
||||
provider: LLMProviderType = "openai"
|
||||
model: str = ""
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
|
||||
# LLM 配置输入
|
||||
class LLMConfigIn(BaseModel):
|
||||
chat: Optional[LLMModelConfig] = None
|
||||
vlm: Optional[LLMModelConfig] = None
|
||||
embedding: Optional[LLMModelConfig] = None
|
||||
rerank: Optional[LLMModelConfig] = None
|
||||
|
||||
# 定时任务配置
|
||||
class SchedulerConfigIn(BaseModel):
|
||||
daily_plan_time: Optional[str] = "08:00"
|
||||
forum_scan_interval_minutes: Optional[int] = 30
|
||||
todo_ai_generate_time: Optional[str] = "08:00"
|
||||
enabled: Optional[bool] = True
|
||||
|
||||
# 用户资料更新
|
||||
class ProfileUpdateIn(BaseModel):
|
||||
full_name: Optional[str] = Field(None, min_length=2, max_length=50)
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
current_password: Optional[str] = None # 修改密码时需要验证
|
||||
|
||||
# 完整设置输出
|
||||
class SettingsOut(BaseModel):
|
||||
profile: "UserOut" # 引用 auth.py 中的 UserOut
|
||||
llm_config: Optional[dict] = None
|
||||
scheduler_config: Optional[dict] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
# 测试 LLM 连接请求
|
||||
class LLMTestIn(BaseModel):
|
||||
type: LLMType
|
||||
provider: LLMProviderType
|
||||
model: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/settings.py
|
||||
git commit -m "feat(settings): add Pydantic schemas for settings API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 后端 - Settings Service
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/settings_service.py`
|
||||
|
||||
- [ ] **Step 1: 创建设置服务**
|
||||
|
||||
主要功能:
|
||||
1. `get_user_settings(user_id)` - 获取用户完整设置
|
||||
2. `update_user_profile(user_id, data)` - 更新用户资料
|
||||
3. `update_llm_config(user_id, config)` - 更新 LLM 配置
|
||||
4. `update_scheduler_config(user_id, config)` - 更新定时任务配置
|
||||
5. `test_llm_connection(data)` - 测试 LLM 连接
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import verify_password, get_password_hash
|
||||
from app.services.llm_service import get_llm
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_user_settings(user_id: str, db: AsyncSession) -> dict:
|
||||
"""获取用户完整设置"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
return {
|
||||
"profile": user,
|
||||
"llm_config": user.llm_config or {},
|
||||
"scheduler_config": user.scheduler_config or {}
|
||||
}
|
||||
|
||||
async def update_user_profile(
|
||||
user_id: str,
|
||||
db: AsyncSession,
|
||||
full_name: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
current_password: Optional[str] = None
|
||||
) -> User:
|
||||
"""更新用户资料"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
if password:
|
||||
if not current_password or not verify_password(current_password, user.hashed_password):
|
||||
raise ValueError("当前密码错误")
|
||||
user.hashed_password = get_password_hash(password)
|
||||
|
||||
if full_name:
|
||||
user.full_name = full_name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
async def update_llm_config(user_id: str, config: dict, db: AsyncSession) -> dict:
|
||||
"""更新 LLM 配置"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
current = user.llm_config or {}
|
||||
# 合并配置
|
||||
for key, value in config.items():
|
||||
if value is not None:
|
||||
current[key] = value
|
||||
user.llm_config = current
|
||||
await db.commit()
|
||||
return current
|
||||
|
||||
async def update_scheduler_config(user_id: str, config: dict, db: AsyncSession) -> dict:
|
||||
"""更新定时任务配置"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
current = user.scheduler_config or {}
|
||||
for key, value in config.items():
|
||||
if value is not None:
|
||||
current[key] = value
|
||||
user.scheduler_config = current
|
||||
await db.commit()
|
||||
return current
|
||||
|
||||
async def test_llm_connection(
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: str,
|
||||
api_key: str
|
||||
) -> dict:
|
||||
"""测试 LLM 连接"""
|
||||
try:
|
||||
# 根据不同 provider 创建临时 LLM 实例并测试
|
||||
if provider == "openai":
|
||||
from langchain_openai import ChatOpenAI
|
||||
llm = ChatOpenAI(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url or None,
|
||||
timeout=30
|
||||
)
|
||||
elif provider == "claude":
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
llm = ChatAnthropic(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
timeout=30
|
||||
)
|
||||
elif provider == "ollama":
|
||||
from langchain_ollama import ChatOllama
|
||||
llm = ChatOllama(
|
||||
base_url=base_url or "http://localhost:11434",
|
||||
model=model,
|
||||
timeout=30
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
from langchain_openai import ChatOpenAI
|
||||
llm = ChatOpenAI(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url or "https://api.deepseek.com/v1",
|
||||
timeout=30
|
||||
)
|
||||
else:
|
||||
return {"success": False, "error": f"不支持的 provider: {provider}"}
|
||||
|
||||
# 简单测试调用
|
||||
response = await llm.ainvoke([HumanMessage(content="Hi")])
|
||||
return {"success": True, "message": f"连接成功,模型响应: {response.content[:50]}..."}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/settings_service.py
|
||||
git commit -m "feat(settings): add settings service with LLM config management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 后端 - Settings Router
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/settings.py`
|
||||
|
||||
- [ ] **Step 1: 创建 settings router**
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.settings import (
|
||||
SettingsOut, ProfileUpdateIn, LLMConfigIn, SchedulerConfigIn, LLMTestIn
|
||||
)
|
||||
from app.services.settings_service import (
|
||||
get_user_settings, update_user_profile, update_llm_config,
|
||||
update_scheduler_config, test_llm_connection
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["设置"])
|
||||
|
||||
@router.get("", response_model=SettingsOut)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
settings = await get_user_settings(current_user.id, db)
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return settings
|
||||
|
||||
@router.put("/profile")
|
||||
async def update_profile(
|
||||
data: ProfileUpdateIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
user = await update_user_profile(
|
||||
current_user.id, db,
|
||||
full_name=data.full_name,
|
||||
password=data.password,
|
||||
current_password=data.current_password
|
||||
)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.put("/llm")
|
||||
async def update_llm(
|
||||
data: LLMConfigIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
config = await update_llm_config(current_user.id, data.model_dump(exclude_none=True), db)
|
||||
return {"llm_config": config}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/llm/test")
|
||||
async def test_llm(
|
||||
data: LLMTestIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await test_llm_connection(
|
||||
provider=data.provider,
|
||||
model=data.model,
|
||||
base_url=data.base_url,
|
||||
api_key=data.api_key
|
||||
)
|
||||
return result
|
||||
|
||||
@router.put("/scheduler")
|
||||
async def update_scheduler(
|
||||
data: SchedulerConfigIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
config = await update_scheduler_config(
|
||||
current_user.id,
|
||||
data.model_dump(exclude_none=True),
|
||||
db
|
||||
)
|
||||
return {"scheduler_config": config}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册 router 到 main.py 和 routers/__init__.py**
|
||||
|
||||
在 `backend/app/routers/__init__.py` 添加:
|
||||
```python
|
||||
from app.routers.settings import router as settings_router
|
||||
```
|
||||
|
||||
在 `backend/app/main.py` 添加:
|
||||
```python
|
||||
from app.routers.settings import router as settings_router
|
||||
# ...
|
||||
app.include_router(settings_router)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/routers/settings.py backend/app/routers/__init__.py backend/app/main.py
|
||||
git commit -m "feat(settings): add settings router with profile, LLM and scheduler endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 前端 - Settings API 客户端
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/settings.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 settings API 客户端**
|
||||
|
||||
```typescript
|
||||
import api from './index'
|
||||
|
||||
export type LLMProvider = 'openai' | 'claude' | 'ollama' | 'deepseek' | 'custom'
|
||||
export type LLMType = 'chat' | 'vlm' | 'embedding' | 'rerank'
|
||||
|
||||
export interface LLMModelConfig {
|
||||
provider: LLMProvider
|
||||
model: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
}
|
||||
|
||||
export interface LLMConfig {
|
||||
chat?: LLMModelConfig
|
||||
vlm?: LLMModelConfig
|
||||
embedding?: LLMModelConfig
|
||||
rerank?: LLMModelConfig
|
||||
}
|
||||
|
||||
export interface SchedulerConfig {
|
||||
daily_plan_time?: string
|
||||
forum_scan_interval_minutes?: number
|
||||
todo_ai_generate_time?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface ProfileUpdate {
|
||||
full_name?: string
|
||||
password?: string
|
||||
current_password?: string
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
profile: {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
created_at: string
|
||||
}
|
||||
llm_config: LLMConfig
|
||||
scheduler_config: SchedulerConfig
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
// 获取设置
|
||||
get() {
|
||||
return api.get<SettingsResponse>('/api/settings')
|
||||
},
|
||||
|
||||
// 更新资料
|
||||
updateProfile(data: ProfileUpdate) {
|
||||
return api.put('/api/settings/profile', data)
|
||||
},
|
||||
|
||||
// 更新 LLM 配置
|
||||
updateLLM(config: Partial<LLMConfig>) {
|
||||
return api.put('/api/settings/llm', config)
|
||||
},
|
||||
|
||||
// 测试 LLM 连接
|
||||
testLLM(data: { type: LLMType } & LLMModelConfig) {
|
||||
return api.post('/api/settings/llm/test', data)
|
||||
},
|
||||
|
||||
// 更新定时任务配置
|
||||
updateScheduler(config: Partial<SchedulerConfig>) {
|
||||
return api.put('/api/settings/scheduler', config)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/settings.ts
|
||||
git commit -m "feat(settings): add settings API client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 前端 - LoginView 注册功能
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/LoginView.vue`
|
||||
|
||||
- [ ] **Step 1: 添加注册 Tab 和表单**
|
||||
|
||||
在 script setup 中添加:
|
||||
```typescript
|
||||
const isLogin = ref(true)
|
||||
const registerEmail = ref('')
|
||||
const registerPassword = ref('')
|
||||
const registerConfirmPassword = ref('')
|
||||
const registerName = ref('')
|
||||
const isRegistering = ref(false)
|
||||
const registerError = ref('')
|
||||
|
||||
// 密码强度计算
|
||||
function getPasswordStrength(pwd: string): { level: 'weak' | 'medium' | 'strong', text: string } {
|
||||
if (pwd.length < 8) return { level: 'weak', text: '太短' }
|
||||
let score = 0
|
||||
if (pwd.length >= 8) score++
|
||||
if (pwd.length >= 12) score++
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
|
||||
if (/\d/.test(pwd)) score++
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) score++
|
||||
if (score <= 2) return { level: 'weak', text: '弱' }
|
||||
if (score <= 3) return { level: 'medium', text: '中' }
|
||||
return { level: 'strong', text: '强' }
|
||||
}
|
||||
|
||||
const passwordStrength = computed(() => getPasswordStrength(registerPassword.value))
|
||||
|
||||
async function handleRegister() {
|
||||
if (registerPassword.value !== registerConfirmPassword.value) {
|
||||
registerError.value = '两次密码输入不一致'
|
||||
return
|
||||
}
|
||||
if (registerPassword.value.length < 8) {
|
||||
registerError.value = '密码至少需要8个字符'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
registerError.value = ''
|
||||
isRegistering.value = true
|
||||
await authApi.register({
|
||||
email: registerEmail.value,
|
||||
password: registerPassword.value,
|
||||
full_name: registerName.value
|
||||
})
|
||||
// 注册成功后自动登录
|
||||
await auth.login(registerEmail.value, registerPassword.value)
|
||||
router.push('/chat')
|
||||
} catch (e: unknown) {
|
||||
registerError.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '注册失败'
|
||||
} finally {
|
||||
isRegistering.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 template 中添加注册表单(与登录表单并列,用 v-if 切换)
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/LoginView.vue
|
||||
git commit -m "feat(auth): add registration form to LoginView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 前端 - SettingsView 页面
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/SettingsView.vue`
|
||||
|
||||
- [ ] **Step 1: 创建设置页面**
|
||||
|
||||
页面结构:
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig } from '@/api/settings'
|
||||
import { Save, RotateCcw, Eye, EyeOff, Play } from 'lucide-vue-next'
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showApiKey = ref<Record<string, boolean>>({})
|
||||
|
||||
// 用户资料
|
||||
const profile = ref({ email: '', full_name: '' })
|
||||
const newPassword = ref('')
|
||||
|
||||
// LLM 配置
|
||||
const llmConfig = ref<LLMConfig>({
|
||||
chat: { provider: 'openai', model: 'gpt-4o', base_url: '', api_key: '' },
|
||||
vlm: { provider: 'openai', model: 'gpt-4o', base_url: '', api_key: '' },
|
||||
embedding: { provider: 'openai', model: 'text-embedding-3-small', base_url: '', api_key: '' },
|
||||
rerank: { provider: 'openai', model: 'bge-reranker-v2', base_url: '', api_key: '' },
|
||||
})
|
||||
|
||||
// 定时任务配置
|
||||
const schedulerConfig = ref<SchedulerConfig>({
|
||||
daily_plan_time: '08:00',
|
||||
forum_scan_interval_minutes: 30,
|
||||
todo_ai_generate_time: '08:00',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// 加载设置
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await settingsApi.get()
|
||||
profile.value = { ...res.data.profile }
|
||||
llmConfig.value = { ...res.data.llm_config }
|
||||
schedulerConfig.value = { ...res.data.scheduler_config }
|
||||
} catch (e) {
|
||||
console.error('加载设置失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存资料
|
||||
async function saveProfile() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.updateProfile({ full_name: profile.value.full_name })
|
||||
if (newPassword.value) {
|
||||
const currentPwd = prompt('请输入当前密码以确认修改:')
|
||||
if (!currentPwd) {
|
||||
alert('密码修改已取消')
|
||||
return
|
||||
}
|
||||
await settingsApi.updateProfile({
|
||||
password: newPassword.value,
|
||||
current_password: currentPwd
|
||||
})
|
||||
newPassword.value = ''
|
||||
alert('密码修改成功')
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 LLM 配置
|
||||
async function saveLLM() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.updateLLM(llmConfig.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 LLM 连接
|
||||
async function testLLM(type: string, config: LLMModelConfig) {
|
||||
try {
|
||||
const res = await settingsApi.testLLM({ type, ...config })
|
||||
alert(res.data.success ? `成功: ${res.data.message}` : `失败: ${res.data.error}`)
|
||||
} catch (e) {
|
||||
alert('测试连接失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存定时任务配置
|
||||
async function saveScheduler() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.updateScheduler(schedulerConfig.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 默认值
|
||||
function getDefaultBaseUrl(provider: string) {
|
||||
switch (provider) {
|
||||
case 'ollama': return 'http://localhost:11434'
|
||||
case 'openai': return 'https://api.openai.com/v1'
|
||||
case 'claude': return 'https://api.anthropic.com'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-view">
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<span class="header-title">SETTINGS</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="settings-content">
|
||||
<!-- Profile Section -->
|
||||
<div class="settings-card">
|
||||
<div class="card-title">PROFILE</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input v-model="profile.email" type="email" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input v-model="profile.full_name" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input v-model="newPassword" type="password" placeholder="Leave empty to keep current" />
|
||||
</div>
|
||||
<button class="save-btn" @click="saveProfile" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Profile' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- LLM Config Section -->
|
||||
<div v-for="(config, type) in llmConfig" :key="type" class="settings-card">
|
||||
<div class="card-title">{{ type.toUpperCase() }}</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Provider</label>
|
||||
<select v-model="config.provider">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Model</label>
|
||||
<input v-model="config.model" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Base URL</label>
|
||||
<input v-model="config.base_url" type="text" :placeholder="getDefaultBaseUrl(config.provider)" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<div class="input-with-toggle">
|
||||
<input v-model="config.api_key" :type="showApiKey[type] ? 'text' : 'password'" />
|
||||
<button @click="showApiKey[type] = !showApiKey[type]" class="toggle-btn">
|
||||
<Eye v-if="!showApiKey[type]" :size="14" />
|
||||
<EyeOff v-else :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="test-btn" @click="testLLM(type, config)">
|
||||
<Play :size="12" /> Test
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scheduler Section -->
|
||||
<div class="settings-card">
|
||||
<div class="card-title">SCHEDULER</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Daily Plan Time</label>
|
||||
<input v-model="schedulerConfig.daily_plan_time" type="time" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Todo AI Generate Time</label>
|
||||
<input v-model="schedulerConfig.todo_ai_generate_time" type="time" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Forum Scan Interval (minutes)</label>
|
||||
<input v-model.number="schedulerConfig.forum_scan_interval_minutes" type="number" min="5" max="1440" />
|
||||
</div>
|
||||
<div class="form-group toggle-group">
|
||||
<label>Scheduler Enabled</label>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: schedulerConfig.enabled }"
|
||||
@click="schedulerConfig.enabled = !schedulerConfig.enabled"
|
||||
>
|
||||
<span class="toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="save-btn" @click="saveScheduler" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Scheduler' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
样式部分复用 AgentView 的 sci-fi 风格,保持一致。
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/SettingsView.vue
|
||||
git commit -m "feat(settings): add SettingsView page with profile, LLM and scheduler config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 前端 - 路由和侧边栏
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
- Modify: `frontend/src/components/SidebarNav.vue`
|
||||
|
||||
- [ ] **Step 1: 添加 /settings 路由**
|
||||
|
||||
在 children 数组中添加:
|
||||
```typescript
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加设置菜单项**
|
||||
|
||||
在 navItems 中添加:
|
||||
```typescript
|
||||
{ name: '设置', path: '/settings', icon: Settings },
|
||||
```
|
||||
|
||||
导入 Settings 图标:
|
||||
```typescript
|
||||
import { Settings } from 'lucide-vue-next'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/router/index.ts frontend/src/components/SidebarNav.vue
|
||||
git commit -m "feat(settings): add /settings route and sidebar menu"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 数据库迁移
|
||||
|
||||
- [ ] **Step 1: 创建迁移 SQL**
|
||||
|
||||
由于使用 SQLAlchemy 的 `init_db()` 会在启动时自动创建表,但现有数据库不会自动添加新字段。需要:
|
||||
|
||||
1. 直接在数据库上执行 ALTER TABLE:
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN llm_config TEXT;
|
||||
ALTER TABLE users ADD COLUMN scheduler_config TEXT;
|
||||
```
|
||||
|
||||
2. 或通过 Python 脚本:
|
||||
```python
|
||||
import asyncio
|
||||
from app.database import engine
|
||||
|
||||
async def migrate():
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN llm_config TEXT'))
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN scheduler_config TEXT'))
|
||||
print('Migration complete')
|
||||
|
||||
asyncio.run(migrate())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交迁移脚本**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-03-20-settings-migration.md
|
||||
git commit -m "feat(settings): add database migration for user settings fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
完成所有 Task 后,验证以下内容:
|
||||
|
||||
1. **注册功能** - 可以通过注册页面创建新账号
|
||||
2. **登录功能** - 新老用户都可以正常登录
|
||||
3. **设置页面** - 可以访问 /settings 页面
|
||||
4. **资料修改** - 用户名、密码可以修改
|
||||
5. **LLM 配置** - 四种模型配置可以保存
|
||||
6. **LLM 测试** - 测试连接功能正常
|
||||
7. **定时任务** - 时间间隔可以修改
|
||||
8. **配置持久化** - 重新登录后配置保留
|
||||
9. **UI 风格** - 设置页面风格与其他页面一致
|
||||
|
||||
---
|
||||
|
||||
## 实现顺序建议
|
||||
|
||||
1. Task 1 → 2 → 3 → 4(后端核心)
|
||||
2. Task 5(前端 API)
|
||||
3. Task 6(LoginView 注册功能)
|
||||
4. Task 7(SettingsView)
|
||||
5. Task 8(路由和侧边栏)
|
||||
6. Task 9(数据库迁移)
|
||||
1010
docs/superpowers/plans/2026-03-20-stats-dashboard-implementation.md
Normal file
1010
docs/superpowers/plans/2026-03-20-stats-dashboard-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
982
docs/superpowers/plans/2026-03-20-stats-dashboard.md
Normal file
982
docs/superpowers/plans/2026-03-20-stats-dashboard.md
Normal file
@@ -0,0 +1,982 @@
|
||||
# Stats Dashboard Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现统计页面,展示系统健康、对话趋势、知识库、看板、社区和个人洞察等6个Tab的指标数据。
|
||||
|
||||
**Architecture:**
|
||||
- 后端:6个统计API端点,按模块分组
|
||||
- 前端:StatsView.vue 包含 6 个 Tab,使用 ECharts 渲染折线图
|
||||
- 数据聚合:SQL GROUP BY date_trunc('day')
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy, ECharts, Vue 3, Element Plus
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── routers/
|
||||
│ └── stats.py # 新建: 统计 API 路由
|
||||
├── services/
|
||||
│ └── stats_service.py # 新建: 统计服务
|
||||
└── schemas/
|
||||
└── stats.py # 新建: 统计 Schema
|
||||
|
||||
frontend/src/
|
||||
├── api/
|
||||
│ └── stats.ts # 新建: 统计 API
|
||||
├── views/
|
||||
│ └── StatsView.vue # 新建: 统计页面
|
||||
└── router/
|
||||
└── index.ts # 修改: 添加 /stats 路由
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create Stats Schema
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/stats.py`
|
||||
|
||||
- [ ] **Step 1: Create stats schemas**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/stats.py
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ===== System Health =====
|
||||
class SystemHealth(BaseModel):
|
||||
uptime_seconds: int
|
||||
cpu_percent: float
|
||||
memory_used_mb: float
|
||||
memory_total_mb: float
|
||||
memory_percent: float
|
||||
disk_used_gb: float
|
||||
disk_total_gb: float
|
||||
disk_percent: float
|
||||
active_users_24h: int
|
||||
|
||||
|
||||
# ===== Daily Stats Base =====
|
||||
class DailyStatItem(BaseModel):
|
||||
date: str
|
||||
count: int
|
||||
|
||||
|
||||
class DailyTokenStatItem(BaseModel):
|
||||
date: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
|
||||
|
||||
# ===== Conversation Stats =====
|
||||
class ConversationStats(BaseModel):
|
||||
daily_conversations: list[DailyStatItem]
|
||||
daily_messages: list[DailyStatItem]
|
||||
daily_input_tokens: list[DailyTokenStatItem]
|
||||
daily_output_tokens: list[DailyTokenStatItem]
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Knowledge Stats =====
|
||||
class KnowledgeStats(BaseModel):
|
||||
daily_new_tags: list[DailyStatItem]
|
||||
daily_documents: list[DailyStatItem]
|
||||
daily_knowledge_queries: list[DailyStatItem]
|
||||
daily_tag_relations: list[DailyStatItem]
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Kanban Stats =====
|
||||
class KanbanStats(BaseModel):
|
||||
daily_new_tasks: list[DailyStatItem]
|
||||
daily_completed_tasks: list[DailyStatItem]
|
||||
daily_completion_rate: list[DailyStatItem]
|
||||
current_pending_tasks: int
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Community Stats =====
|
||||
class CommunityStats(BaseModel):
|
||||
daily_posts: list[DailyStatItem]
|
||||
daily_replies: list[DailyStatItem]
|
||||
daily_ai_executions: list[DailyStatItem]
|
||||
daily_agent_calls: list[DailyStatItem]
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Personal Insights =====
|
||||
class HourlyActivity(BaseModel):
|
||||
hour: int
|
||||
count: int
|
||||
|
||||
|
||||
class TagUsage(BaseModel):
|
||||
tag_path: str
|
||||
usage_count: int
|
||||
|
||||
|
||||
class PersonalInsights(BaseModel):
|
||||
hourly_activity: list[HourlyActivity]
|
||||
top_tags: list[TagUsage]
|
||||
token_trend_percent: float
|
||||
this_month_tokens: int
|
||||
last_month_tokens: int
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create Stats Service
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/stats_service.py`
|
||||
|
||||
- [ ] **Step 1: Create stats service**
|
||||
|
||||
```python
|
||||
# backend/app/services/stats_service.py
|
||||
import psutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.conversation import Conversation, Message
|
||||
from app.models.knowledge_graph import KGNode, KGEdge
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.forum import ForumPost, ForumReply
|
||||
from app.models.document import Document
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class StatsService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_system_health(self) -> dict:
|
||||
"""获取系统健康指标"""
|
||||
# Uptime (假设进程启动时间)
|
||||
uptime_seconds = int(time.time() - psutil.boot_time())
|
||||
|
||||
# CPU
|
||||
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||
|
||||
# Memory
|
||||
mem = psutil.virtual_memory()
|
||||
memory_used_mb = mem.used / (1024 * 1024)
|
||||
memory_total_mb = mem.total / (1024 * 1024)
|
||||
memory_percent = mem.percent
|
||||
|
||||
# Disk
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_used_gb = disk.used / (1024 * 1024 * 1024)
|
||||
disk_total_gb = disk.total / (1024 * 1024 * 1024)
|
||||
disk_percent = disk.percent
|
||||
|
||||
# Active users (24h)
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
active_users = self.db.query(func.count(func.distinct(User.id))).filter(
|
||||
User.updated_at >= yesterday
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory_used_mb": round(memory_used_mb, 1),
|
||||
"memory_total_mb": round(memory_total_mb, 1),
|
||||
"memory_percent": memory_percent,
|
||||
"disk_used_gb": round(disk_used_gb, 1),
|
||||
"disk_total_gb": round(disk_total_gb, 1),
|
||||
"disk_percent": disk_percent,
|
||||
"active_users_24h": active_users,
|
||||
}
|
||||
|
||||
def _get_daily_stats(self, model, date_column, user_id=None, days=30) -> list:
|
||||
"""通用每日统计查询"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
query = self.db.query(
|
||||
func.date(date_column).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(date_column >= cutoff)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(model.user_id == user_id)
|
||||
|
||||
query = query.group_by(func.date(date_column)).order_by(func.date(date_column))
|
||||
results = query.all()
|
||||
return [{"date": str(r.date), "count": r.count} for r in results]
|
||||
|
||||
def get_conversation_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取对话统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Daily conversations
|
||||
daily_conversations = self._get_daily_stats(
|
||||
Conversation, Conversation.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Daily messages
|
||||
daily_messages = self._get_daily_stats(
|
||||
Message, Message.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Daily tokens (input vs output - approximated by role)
|
||||
input_query = self.db.query(
|
||||
func.date(Message.created_at).label('date'),
|
||||
func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
|
||||
).filter(
|
||||
Message.created_at >= cutoff,
|
||||
Message.role == 'user'
|
||||
)
|
||||
if user_id:
|
||||
input_query = input_query.join(Conversation).filter(Conversation.user_id == user_id)
|
||||
input_query = input_query.group_by(func.date(Message.created_at))
|
||||
input_results = input_query.all()
|
||||
|
||||
output_query = self.db.query(
|
||||
func.date(Message.created_at).label('date'),
|
||||
func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
|
||||
).filter(
|
||||
Message.created_at >= cutoff,
|
||||
Message.role == 'assistant'
|
||||
)
|
||||
if user_id:
|
||||
output_query = output_query.join(Conversation).filter(Conversation.user_id == user_id)
|
||||
output_query = output_query.group_by(func.date(Message.created_at))
|
||||
output_results = output_query.all()
|
||||
|
||||
daily_input_tokens = [
|
||||
{"date": str(r.date), "input_tokens": r.tokens}
|
||||
for r in input_results
|
||||
]
|
||||
daily_output_tokens = [
|
||||
{"date": str(r.date), "output_tokens": r.tokens}
|
||||
for r in output_results
|
||||
]
|
||||
|
||||
total_conversations = sum(c["count"] for c in daily_conversations)
|
||||
total_messages = sum(m["count"] for m in daily_messages)
|
||||
total_input = sum(t["input_tokens"] for t in daily_input_tokens)
|
||||
total_output = sum(t["output_tokens"] for t in daily_output_tokens)
|
||||
|
||||
return {
|
||||
"daily_conversations": daily_conversations,
|
||||
"daily_messages": daily_messages,
|
||||
"daily_input_tokens": daily_input_tokens,
|
||||
"daily_output_tokens": daily_output_tokens,
|
||||
"totals": {
|
||||
"conversations": total_conversations,
|
||||
"messages": total_messages,
|
||||
"input_tokens": total_input,
|
||||
"output_tokens": total_output,
|
||||
}
|
||||
}
|
||||
|
||||
def get_knowledge_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取知识库统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# New tags
|
||||
daily_new_tags = self._get_daily_stats(
|
||||
KGNode, KGNode.created_at, user_id, days
|
||||
)
|
||||
# Filter by tag type if user_id provided
|
||||
if user_id:
|
||||
tag_query = self.db.query(
|
||||
func.date(KGNode.created_at).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
KGNode.created_at >= cutoff,
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type == 'tag'
|
||||
).group_by(func.date(KGNode.created_at))
|
||||
daily_new_tags = [{"date": str(r.date), "count": r.count} for r in tag_query.all()]
|
||||
|
||||
# Documents
|
||||
daily_documents = self._get_daily_stats(
|
||||
Document, Document.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Tag relations
|
||||
daily_tag_relations = self._get_daily_stats(
|
||||
KGEdge, KGEdge.created_at, user_id, days
|
||||
)
|
||||
|
||||
return {
|
||||
"daily_new_tags": daily_new_tags,
|
||||
"daily_documents": daily_documents,
|
||||
"daily_knowledge_queries": [], # 需要 Chroma 查询日志
|
||||
"daily_tag_relations": daily_tag_relations,
|
||||
"totals": {
|
||||
"new_tags": sum(t["count"] for t in daily_new_tags),
|
||||
"documents": sum(d["count"] for d in daily_documents),
|
||||
"tag_relations": sum(r["count"] for r in daily_tag_relations),
|
||||
}
|
||||
}
|
||||
|
||||
def get_kanban_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取看板统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# New tasks
|
||||
daily_new_tasks = self._get_daily_stats(
|
||||
Task, Task.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Completed tasks
|
||||
daily_completed = []
|
||||
completed_query = self.db.query(
|
||||
func.date(Task.completed_at).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
Task.completed_at >= cutoff,
|
||||
Task.status == TaskStatus.DONE
|
||||
)
|
||||
if user_id:
|
||||
completed_query = completed_query.filter(Task.user_id == user_id)
|
||||
completed_query = completed_query.group_by(func.date(Task.completed_at))
|
||||
daily_completed = [{"date": str(r.date), "count": r.count} for r in completed_query.all()]
|
||||
|
||||
# Current pending tasks
|
||||
pending_count = self.db.query(func.count(Task.id)).filter(
|
||||
Task.status == TaskStatus.TODO
|
||||
)
|
||||
if user_id:
|
||||
pending_count = pending_count.filter(Task.user_id == user_id)
|
||||
current_pending = pending_count.scalar() or 0
|
||||
|
||||
# Completion rate (daily)
|
||||
daily_new_dict = {d["date"]: d["count"] for d in daily_new_tasks}
|
||||
daily_completed_dict = {d["date"]: d["count"] for d in daily_completed}
|
||||
all_dates = set(daily_new_dict.keys()) | set(daily_completed_dict.keys())
|
||||
daily_completion_rate = []
|
||||
for date in sorted(all_dates):
|
||||
new = daily_new_dict.get(date, 0)
|
||||
completed = daily_completed_dict.get(date, 0)
|
||||
rate = (completed / new * 100) if new > 0 else 0
|
||||
daily_completion_rate.append({"date": date, "rate": round(rate, 1)})
|
||||
|
||||
return {
|
||||
"daily_new_tasks": daily_new_tasks,
|
||||
"daily_completed_tasks": daily_completed,
|
||||
"daily_completion_rate": daily_completion_rate,
|
||||
"current_pending_tasks": current_pending,
|
||||
"totals": {
|
||||
"new_tasks": sum(t["count"] for t in daily_new_tasks),
|
||||
"completed_tasks": sum(c["count"] for c in daily_completed),
|
||||
}
|
||||
}
|
||||
|
||||
def get_community_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取社区统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Posts
|
||||
daily_posts = self._get_daily_stats(
|
||||
ForumPost, ForumPost.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Replies
|
||||
daily_replies = self._get_daily_stats(
|
||||
ForumReply, ForumReply.created_at, user_id, days
|
||||
)
|
||||
|
||||
# AI executions
|
||||
daily_ai_executions = []
|
||||
ai_query = self.db.query(
|
||||
func.date(ForumPost.updated_at).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
ForumPost.updated_at >= cutoff,
|
||||
ForumPost.is_executed == True
|
||||
)
|
||||
if user_id:
|
||||
ai_query = ai_query.filter(ForumPost.user_id == user_id)
|
||||
ai_query = ai_query.group_by(func.date(ForumPost.updated_at))
|
||||
daily_ai_executions = [{"date": str(r.date), "count": r.count} for r in ai_query.all()]
|
||||
|
||||
return {
|
||||
"daily_posts": daily_posts,
|
||||
"daily_replies": daily_replies,
|
||||
"daily_ai_executions": daily_ai_executions,
|
||||
"daily_agent_calls": [], # 需要 AgentMessage 表
|
||||
"totals": {
|
||||
"posts": sum(p["count"] for p in daily_posts),
|
||||
"replies": sum(r["count"] for r in daily_replies),
|
||||
"ai_executions": sum(a["count"] for a in daily_ai_executions),
|
||||
}
|
||||
}
|
||||
|
||||
def get_personal_insights(self, user_id: str) -> dict:
|
||||
"""获取个人洞察"""
|
||||
# Hourly activity
|
||||
hourly_query = self.db.query(
|
||||
func.extract('hour', Conversation.created_at).label('hour'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
Conversation.user_id == user_id
|
||||
).group_by(func.extract('hour', Conversation.created_at))
|
||||
hourly_results = hourly_query.all()
|
||||
hourly_activity = [{"hour": int(r.hour), "count": r.count} for r in hourly_results]
|
||||
|
||||
# Top tags
|
||||
tag_query = self.db.query(
|
||||
KGNode.properties_["tag_path"].astext.label('tag_path'),
|
||||
func.count(KGEdge.id).label('usage_count')
|
||||
).join(
|
||||
KGEdge, KGEdge.target_id == KGNode.id
|
||||
).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type == 'tag',
|
||||
KGEdge.relation_type == 'has_tag'
|
||||
).group_by(
|
||||
KGNode.properties_["tag_path"].astext
|
||||
).order_by(func.count(KGEdge.id).desc()).limit(5)
|
||||
top_tags = [{"tag_path": r.tag_path, "usage_count": r.usage_count} for r in tag_query.all()]
|
||||
|
||||
# Token trend (this month vs last month)
|
||||
now = datetime.utcnow()
|
||||
this_month_start = datetime(now.year, now.month, 1)
|
||||
last_month_end = this_month_start - timedelta(days=1)
|
||||
last_month_start = datetime(last_month_end.year, last_month_end.month, 1)
|
||||
|
||||
this_month_tokens = self.db.query(
|
||||
func.coalesce(func.sum(Message.tokens_used), 0)
|
||||
).join(Conversation).filter(
|
||||
Conversation.user_id == user_id,
|
||||
Message.created_at >= this_month_start,
|
||||
Message.role == 'assistant'
|
||||
).scalar() or 0
|
||||
|
||||
last_month_tokens = self.db.query(
|
||||
func.coalesce(func.sum(Message.tokens_used), 0)
|
||||
).join(Conversation).filter(
|
||||
Conversation.user_id == user_id,
|
||||
Message.created_at >= last_month_start,
|
||||
Message.created_at < this_month_start,
|
||||
Message.role == 'assistant'
|
||||
).scalar() or 0
|
||||
|
||||
token_trend = 0
|
||||
if last_month_tokens > 0:
|
||||
token_trend = round((this_month_tokens - last_month_tokens) / last_month_tokens * 100, 1)
|
||||
|
||||
return {
|
||||
"hourly_activity": hourly_activity,
|
||||
"top_tags": top_tags,
|
||||
"token_trend_percent": token_trend,
|
||||
"this_month_tokens": this_month_tokens,
|
||||
"last_month_tokens": last_month_tokens,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Stats Router
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/stats.py`
|
||||
|
||||
- [ ] **Step 1: Create stats router**
|
||||
|
||||
```python
|
||||
# backend/app/routers/stats.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.stats import (
|
||||
SystemHealth,
|
||||
ConversationStats,
|
||||
KnowledgeStats,
|
||||
KanbanStats,
|
||||
CommunityStats,
|
||||
PersonalInsights,
|
||||
)
|
||||
from app.services.stats_service import StatsService
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["统计"])
|
||||
|
||||
|
||||
@router.get("/system", response_model=SystemHealth)
|
||||
async def get_system_health(db: Session = Depends(get_db)):
|
||||
"""获取系统健康指标"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_system_health()
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=ConversationStats)
|
||||
async def get_conversation_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取对话统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_conversation_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/knowledge", response_model=KnowledgeStats)
|
||||
async def get_knowledge_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取知识库统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_knowledge_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/kanban", response_model=KanbanStats)
|
||||
async def get_kanban_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取看板统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_kanban_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/community", response_model=CommunityStats)
|
||||
async def get_community_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取社区统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_community_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/insights", response_model=PersonalInsights)
|
||||
async def get_personal_insights(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取个人洞察"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_personal_insights(user_id=current_user.id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register router in main app**
|
||||
|
||||
在 `backend/app/__init__.py` 或 `main.py` 中添加:
|
||||
```python
|
||||
from app.routers import stats
|
||||
app.include_router(stats.router)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create Frontend API
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/stats.ts`
|
||||
|
||||
- [ ] **Step 1: Create stats API**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/stats.ts
|
||||
import axios from '@/api'
|
||||
|
||||
export const getSystemHealth = () => axios.get('/stats/system')
|
||||
|
||||
export const getConversationStats = (days = 30) =>
|
||||
axios.get('/stats/conversations', { params: { days } })
|
||||
|
||||
export const getKnowledgeStats = (days = 30) =>
|
||||
axios.get('/stats/knowledge', { params: { days } })
|
||||
|
||||
export const getKanbanStats = (days = 30) =>
|
||||
axios.get('/stats/kanban', { params: { days } })
|
||||
|
||||
export const getCommunityStats = (days = 30) =>
|
||||
axios.get('/stats/community', { params: { days } })
|
||||
|
||||
export const getPersonalInsights = () => axios.get('/stats/insights')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create StatsView Component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/StatsView.vue`
|
||||
|
||||
- [ ] **Step 1: Create StatsView with 6 tabs**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as statsApi from '@/api/stats'
|
||||
import * as echarts from 'echarts'
|
||||
import {
|
||||
Cpu, HardDrive, MemoryStick, Users, Activity,
|
||||
MessageSquare, BookOpen, CheckSquare, Forum,
|
||||
TrendingUp, Clock, Tag, Zap
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const activeTab = ref('system')
|
||||
const systemHealth = ref<any>(null)
|
||||
const conversationStats = ref<any>(null)
|
||||
const knowledgeStats = ref<any>(null)
|
||||
const kanbanStats = ref<any>(null)
|
||||
const communityStats = ref<any>(null)
|
||||
const personalInsights = ref<any>(null)
|
||||
|
||||
// Format uptime
|
||||
function formatUptime(seconds: number) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
return `${days}d ${hours}h ${mins}m`
|
||||
}
|
||||
|
||||
// Chart refs
|
||||
const convChartRef = ref<HTMLElement>()
|
||||
const knowChartRef = ref<HTMLElement>()
|
||||
const kanbanChartRef = ref<HTMLElement>()
|
||||
const communityChartRef = ref<HTMLElement>()
|
||||
const hourlyChartRef = ref<HTMLElement>()
|
||||
|
||||
onMounted(async () => {
|
||||
const [sys, conv, know, kanban, community, insights] = await Promise.all([
|
||||
statsApi.getSystemHealth(),
|
||||
statsApi.getConversationStats(),
|
||||
statsApi.getKnowledgeStats(),
|
||||
statsApi.getKanbanStats(),
|
||||
statsApi.getCommunityStats(),
|
||||
statsApi.getPersonalInsights(),
|
||||
])
|
||||
systemHealth.value = sys.data
|
||||
conversationStats.value = conv.data
|
||||
knowledgeStats.value = know.data
|
||||
kanbanStats.value = kanban.data
|
||||
communityStats.value = community.data
|
||||
personalInsights.value = insights.data
|
||||
|
||||
// Render charts
|
||||
renderLineChart(convChartRef.value, conv.data)
|
||||
renderLineChart(knowChartRef.value, know.data)
|
||||
renderKanbanChart(kanbanChartRef.value, kanban.data)
|
||||
renderLineChart(communityChartRef.value, community.data)
|
||||
renderHourlyChart(hourlyChartRef.value, insights.data)
|
||||
})
|
||||
|
||||
function renderLineChart(el: HTMLElement, data: any) {
|
||||
if (!el || !data) return
|
||||
const chart = echarts.init(el)
|
||||
const dates = data.daily_conversations?.map((d: any) => d.date) || []
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: Object.keys(data).filter(k => k.startsWith('daily_')) },
|
||||
xAxis: { type: 'category', data: dates },
|
||||
yAxis: { type: 'value' },
|
||||
series: Object.entries(data).filter(([k]) => k.startsWith('daily_')).map(([name, values]) => ({
|
||||
name: name.replace('daily_', ''),
|
||||
type: 'line',
|
||||
data: (values as any[]).map((v: any) => v.count || v.input_tokens || v.output_tokens || 0)
|
||||
}))
|
||||
}
|
||||
chart.setOption(option)
|
||||
}
|
||||
|
||||
function renderKanbanChart(el: HTMLElement, data: any) {
|
||||
if (!el || !data) return
|
||||
const chart = echarts.init(el)
|
||||
const dates = [...new Set([
|
||||
...data.daily_new_tasks.map((d: any) => d.date),
|
||||
...data.daily_completed_tasks.map((d: any) => d.date)
|
||||
])].sort()
|
||||
|
||||
option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新建任务', '完成任务'] },
|
||||
xAxis: { type: 'category', data: dates },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '新建任务', type: 'bar', data: dates.map(d => data.daily_new_tasks.find((t: any) => t.date === d)?.count || 0) },
|
||||
{ name: '完成任务', type: 'bar', data: dates.map(d => data.daily_completed_tasks.find((t: any) => t.date === d)?.count || 0) }
|
||||
]
|
||||
}
|
||||
chart.setOption(option)
|
||||
}
|
||||
|
||||
function renderHourlyChart(el: HTMLElement, data: any) {
|
||||
if (!el || !data) return
|
||||
const chart = echarts.init(el)
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const counts = hours.map(h => data.hourly_activity.find((a: any) => a.hour === h)?.count || 0)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: hours.map(h => `${h}:00`) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'bar', data: counts }]
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats-view">
|
||||
<div class="stats-header">
|
||||
<h1>数据统计</h1>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<!-- Tab 1: System Health -->
|
||||
<el-tab-pane label="系统健康" name="system">
|
||||
<div class="metrics-grid" v-if="systemHealth">
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><Clock /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ formatUptime(systemHealth.uptime_seconds) }}</span>
|
||||
<span class="metric-label">运行时间</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><Cpu /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.cpu_percent }}%</span>
|
||||
<span class="metric-label">CPU 使用率</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><MemoryStick /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.memory_percent }}%</span>
|
||||
<span class="metric-label">内存占用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><HardDrive /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.disk_percent }}%</span>
|
||||
<span class="metric-label">磁盘使用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><Users /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.active_users_24h }}</span>
|
||||
<span class="metric-label">活跃用户(24h)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: Conversations -->
|
||||
<el-tab-pane label="对话统计" name="conversations">
|
||||
<div class="chart-container" ref="convChartRef"></div>
|
||||
<div class="totals-row" v-if="conversationStats">
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.conversations }}</span>
|
||||
<span class="total-label">对话总数</span>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.messages }}</span>
|
||||
<span class="total-label">消息总数</span>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.input_tokens }}</span>
|
||||
<span class="total-label">Input Tokens</span>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.output_tokens }}</span>
|
||||
<span class="total-label">Output Tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: Knowledge -->
|
||||
<el-tab-pane label="知识库" name="knowledge">
|
||||
<div class="chart-container" ref="knowChartRef"></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: Kanban -->
|
||||
<el-tab-pane label="看板" name="kanban">
|
||||
<div class="chart-container" ref="kanbanChartRef"></div>
|
||||
<div class="totals-row" v-if="kanbanStats">
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ kanbanStats.current_pending_tasks }}</span>
|
||||
<span class="total-label">待办任务</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 5: Community -->
|
||||
<el-tab-pane label="社区" name="community">
|
||||
<div class="chart-container" ref="communityChartRef"></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 6: Personal Insights -->
|
||||
<el-tab-pane label="个人洞察" name="insights">
|
||||
<div class="insights-grid" v-if="personalInsights">
|
||||
<div class="insight-card">
|
||||
<h3>活跃时段</h3>
|
||||
<div class="chart-small" ref="hourlyChartRef"></div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h3>常用标签 Top5</h3>
|
||||
<ul class="tag-list">
|
||||
<li v-for="tag in personalInsights.top_tags" :key="tag.tag_path">
|
||||
<Tag /> {{ tag.tag_path }} ({{ tag.usage_count }})
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h3>Token 消耗趋势</h3>
|
||||
<div class="trend-value" :class="personalInsights.token_trend_percent > 0 ? 'up' : 'down'">
|
||||
<TrendingUp /> {{ personalInsights.token_trend_percent }}%
|
||||
</div>
|
||||
<p>本月 vs 上月</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-view {
|
||||
padding: 24px;
|
||||
}
|
||||
.stats-header h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bg-panel);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.metric-icon {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.metric-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.totals-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
.total-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.total-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.total-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.insights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.insight-card {
|
||||
background: var(--bg-panel);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.insight-card h3 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chart-small {
|
||||
height: 200px;
|
||||
}
|
||||
.tag-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.tag-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.trend-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.trend-value.up { color: var(--accent-red); }
|
||||
.trend-value.down { color: var(--accent-green); }
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add Route and Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
- Modify: `frontend/src/components/SidebarNav.vue`
|
||||
|
||||
- [ ] **Step 1: Add route**
|
||||
|
||||
```typescript
|
||||
// frontend/src/router/index.ts
|
||||
{
|
||||
path: 'stats',
|
||||
name: 'stats',
|
||||
component: () => import('@/views/StatsView.vue'),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add navigation item**
|
||||
|
||||
```typescript
|
||||
// frontend/src/components/SidebarNav.vue
|
||||
// Add to navItems array:
|
||||
{ name: '统计', path: '/stats', icon: Activity },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | Files |
|
||||
|------|-------------|-------|
|
||||
| 1 | Stats Schema | `schemas/stats.py` |
|
||||
| 2 | Stats Service | `services/stats_service.py` |
|
||||
| 3 | Stats Router | `routers/stats.py` |
|
||||
| 4 | Frontend API | `api/stats.ts` |
|
||||
| 5 | StatsView Component | `views/StatsView.vue` |
|
||||
| 6 | Route & Navigation | `router/index.ts`, `SidebarNav.vue` |
|
||||
739
docs/superpowers/plans/2026-03-20-tag-system.md
Normal file
739
docs/superpowers/plans/2026-03-20-tag-system.md
Normal file
@@ -0,0 +1,739 @@
|
||||
# Tag System Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现一个层级标签系统,标签作为 KGNode(entity_type="tag"),支持 AI 自动生成标签、标签关系网络、内容关联发现。
|
||||
|
||||
**Architecture:**
|
||||
- 标签存储为 KGNode(entity_type="tag"),使用路径格式(tag_path)表示层级
|
||||
- 标签关系通过 KGEdge 表示(parent_of, related_to, synonym_of)
|
||||
- 内容节点通过 `properties_.tag_node_ids` 关联到标签节点
|
||||
- AI 服务自动从内容中提取标签并建立标签关系
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy, LLM API
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── models/
|
||||
│ └── knowledge_graph.py # 修改: KGNode 支持 tag 节点
|
||||
├── schemas/
|
||||
│ └── graph.py # 修改: 新增 TagSchema
|
||||
├── services/
|
||||
│ ├── tag_service.py # 新建: 标签提取与管理服务
|
||||
│ └── __init__.py
|
||||
└── routers/
|
||||
└── graph.py # 修改: 新增标签相关 API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend KGNode Properties for Tags
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/knowledge_graph.py:1-33`
|
||||
- Modify: `backend/app/schemas/graph.py:1-35`
|
||||
|
||||
- [ ] **Step 1: Add TagProperties schema**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/graph.py
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
class TagProperties(BaseModel):
|
||||
tag_path: str = Field(..., description="完整标签路径,如 '编程语言/Python/异步'")
|
||||
short_name: str = Field(..., description="显示名称,如 '异步'")
|
||||
level: int = Field(..., ge=1, description="层级深度,1为顶级")
|
||||
parent_path: str | None = Field(None, description="父路径,如 '编程语言/Python'")
|
||||
description: str | None = Field(None, description="AI生成的标签描述")
|
||||
color: str | None = Field(None, description="标签颜色,如 '#FF5733'")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update KGNodeOut schema**
|
||||
|
||||
```python
|
||||
class KGNodeOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
entity_type: str
|
||||
description: str | None
|
||||
properties_: dict | None
|
||||
importance: float
|
||||
created_at: str
|
||||
# 新增:如果是 tag 节点,返回 tag 属性
|
||||
tag_properties: TagProperties | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
def model_post_init(self, __context):
|
||||
if self.entity_type == "tag" and self.properties_:
|
||||
self.tag_properties = TagProperties(**self.properties_)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update KGNode model comment**
|
||||
|
||||
在 `KGNode` 类的 `entity_type` 注释中新增 `"tag"` 选项:
|
||||
```python
|
||||
entity_type = Column(String(100), nullable=False) # person, concept, task, document, chunk, tag
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/knowledge_graph.py backend/app/schemas/graph.py
|
||||
git commit -m "feat: extend KGNode for tag support with path-based hierarchy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create Tag Service
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/tag_service.py`
|
||||
|
||||
- [ ] **Step 1: Write tag extraction prompt template**
|
||||
|
||||
```python
|
||||
# backend/app/services/tag_service.py
|
||||
TAG_EXTRACTION_PROMPT = """你是一个知识分类专家。从给定内容中提取标签。
|
||||
|
||||
要求:
|
||||
1. 标签采用层级路径格式,如 "编程语言/Python"、"后端/框架/FastAPI"
|
||||
2. 层级深度 1-4 层,避免过深
|
||||
3. 每个内容提取 3-8 个标签
|
||||
4. 标签应覆盖:主题、技术栈、领域、任务类型等维度
|
||||
|
||||
输出格式(JSON数组):
|
||||
[
|
||||
{{"path": "编程语言/Python", "description": "Python编程语言相关"}},
|
||||
{{"path": "后端/框架/FastAPI", "description": "FastAPI框架相关"}}
|
||||
]
|
||||
|
||||
内容:
|
||||
{content}
|
||||
"""
|
||||
|
||||
TAG_RELATION_PROMPT = """分析以下标签之间的关系,输出 JSON 数组:
|
||||
|
||||
关系类型:
|
||||
- parent_of: 父子关系(上级包含下级)
|
||||
- related_to: 语义相关(但不是父子)
|
||||
- synonym_of: 同义词
|
||||
|
||||
标签列表:
|
||||
{tag_paths}
|
||||
|
||||
输出格式:
|
||||
[
|
||||
{{"source": "标签1", "target": "标签2", "relation": "related_to", "weight": 0.8}},
|
||||
{{"source": "标签1", "target": "标签3", "relation": "parent_of", "weight": 1.0}}
|
||||
]
|
||||
"""
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write TagService class**
|
||||
|
||||
```python
|
||||
# backend/app/services/tag_service.py
|
||||
import json
|
||||
import re
|
||||
from typing import Annotated
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.knowledge_graph import KGNode, KGEdge
|
||||
|
||||
class TagService:
|
||||
def __init__(self, db: Session, llm_client):
|
||||
self.db = db
|
||||
self.llm_client = llm_client
|
||||
|
||||
def extract_tags_from_content(
|
||||
self, content: str, user_id: str
|
||||
) -> list[dict]:
|
||||
"""从内容中提取标签"""
|
||||
response = self.llm_client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "system", "content": "你是一个知识分类专家。"},
|
||||
{"role": "user", "content": TAG_EXTRACTION_PROMPT.format(content=content)}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return result.get("tags", [])
|
||||
|
||||
def parse_tag_path(self, path: str) -> tuple[str, int, str | None]:
|
||||
"""解析标签路径,返回 (short_name, level, parent_path)"""
|
||||
parts = path.strip("/").split("/")
|
||||
short_name = parts[-1]
|
||||
level = len(parts)
|
||||
parent_path = "/".join(parts[:-1]) if level > 1 else None
|
||||
return short_name, level, parent_path
|
||||
|
||||
def get_or_create_tag_node(
|
||||
self, tag_info: dict, user_id: str
|
||||
) -> KGNode:
|
||||
"""获取或创建标签节点"""
|
||||
path = tag_info["path"]
|
||||
existing = self.db.query(KGNode).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.properties_["tag_path"].astext == path
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
short_name, level, parent_path = self.parse_tag_path(path)
|
||||
|
||||
node = KGNode(
|
||||
user_id=user_id,
|
||||
name=short_name,
|
||||
entity_type="tag",
|
||||
description=tag_info.get("description"),
|
||||
properties_={
|
||||
"tag_path": path,
|
||||
"short_name": short_name,
|
||||
"level": level,
|
||||
"parent_path": parent_path,
|
||||
"description": tag_info.get("description"),
|
||||
"color": tag_info.get("color"),
|
||||
},
|
||||
importance=0.5
|
||||
)
|
||||
self.db.add(node)
|
||||
self.db.flush()
|
||||
return node
|
||||
|
||||
def ensure_parent_tags(self, path: str, user_id: str) -> list[KGNode]:
|
||||
"""确保父路径标签存在"""
|
||||
parts = path.strip("/").split("/")
|
||||
nodes = []
|
||||
for i in range(1, len(parts)):
|
||||
parent_path = "/".join(parts[:i])
|
||||
tag_info = {"path": parent_path, "description": None}
|
||||
node = self.get_or_create_tag_node(tag_info, user_id)
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
def create_tag_relations(
|
||||
self, tag_paths: list[str], user_id: str
|
||||
) -> list[KGEdge]:
|
||||
"""分析并创建标签之间的关系边"""
|
||||
# 构建标签节点映射
|
||||
path_to_node = {}
|
||||
for path in tag_paths:
|
||||
node = self.db.query(KGNode).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.properties_["tag_path"].astext == path,
|
||||
KGNode.entity_type == "tag"
|
||||
).first()
|
||||
if node:
|
||||
path_to_node[path] = node
|
||||
|
||||
# 调用 LLM 分析关系
|
||||
response = self.llm_client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "system", "content": "你是一个知识图谱专家。"},
|
||||
{"role": "user", "content": TAG_RELATION_PROMPT.format(tag_paths=json.dumps(tag_paths))}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
relations = result.get("relations", [])
|
||||
|
||||
edges = []
|
||||
for rel in relations:
|
||||
source_node = path_to_node.get(rel["source"])
|
||||
target_node = path_to_node.get(rel["target"])
|
||||
if source_node and target_node:
|
||||
# 检查边是否已存在
|
||||
existing = self.db.query(KGEdge).filter(
|
||||
KGEdge.source_id == source_node.id,
|
||||
KGEdge.target_id == target_node.id
|
||||
).first()
|
||||
if not existing:
|
||||
edge = KGEdge(
|
||||
source_id=source_node.id,
|
||||
target_id=target_node.id,
|
||||
relation_type=rel["relation"],
|
||||
weight=rel.get("weight", 0.5)
|
||||
)
|
||||
self.db.add(edge)
|
||||
edges.append(edge)
|
||||
|
||||
self.db.flush()
|
||||
return edges
|
||||
|
||||
def tag_content(
|
||||
self, content: str, user_id: str, content_node: KGNode
|
||||
) -> list[KGNode]:
|
||||
"""为内容节点打标签"""
|
||||
# 1. 提取标签
|
||||
tag_infos = self.extract_tags_from_content(content, user_id)
|
||||
tag_paths = [t["path"] for t in tag_infos]
|
||||
|
||||
# 2. 创建/获取标签节点(包含父路径)
|
||||
tag_nodes = []
|
||||
for tag_info in tag_infos:
|
||||
node = self.get_or_create_tag_node(tag_info, user_id)
|
||||
tag_nodes.append(node)
|
||||
# 确保父标签存在
|
||||
self.ensure_parent_tags(tag_info["path"], user_id)
|
||||
|
||||
# 3. 更新内容节点的 tag_node_ids
|
||||
tag_node_ids = [n.id for n in tag_nodes]
|
||||
current_tag_ids = content_node.properties_.get("tag_node_ids", [])
|
||||
content_node.properties_["tag_node_ids"] = list(set(current_tag_ids + tag_node_ids))
|
||||
|
||||
# 4. 分析并创建标签关系
|
||||
if len(tag_paths) >= 2:
|
||||
self.create_tag_relations(tag_paths, user_id)
|
||||
|
||||
self.db.commit()
|
||||
return tag_nodes
|
||||
|
||||
def get_related_content(
|
||||
self, tag_node_ids: list[str], user_id: str, limit: int = 10
|
||||
) -> list[tuple[KGNode, float]]:
|
||||
"""通过标签找相关内容"""
|
||||
# 找所有关联到这些标签的内容节点
|
||||
edges = self.db.query(KGEdge).filter(
|
||||
KGEdge.target_id.in_(tag_node_ids),
|
||||
KGEdge.relation_type == "has_tag"
|
||||
).all()
|
||||
|
||||
# 统计共现次数作为权重
|
||||
content_weights: dict[str, float] = {}
|
||||
for edge in edges:
|
||||
content_weights[edge.source_id] = content_weights.get(edge.source_id, 0) + edge.weight
|
||||
|
||||
# 获取内容节点
|
||||
content_ids = list(content_weights.keys())
|
||||
content_nodes = self.db.query(KGNode).filter(
|
||||
KGNode.id.in_(content_ids),
|
||||
KGNode.entity_type.in_(["conversation", "document", "chunk"])
|
||||
).all()
|
||||
|
||||
return [(node, content_weights[node.id]) for node in content_nodes]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/tag_service.py
|
||||
git commit -m "feat: add TagService for AI-powered tag extraction and management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add Tag API Routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/graph.py`
|
||||
|
||||
- [ ] **Step 1: Read existing router**
|
||||
|
||||
```bash
|
||||
cat backend/app/routers/graph.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add tag-related schemas**
|
||||
|
||||
```python
|
||||
# 在 graph.py 末尾添加
|
||||
class TagExtractRequest(BaseModel):
|
||||
content: str = Field(..., min_length=10)
|
||||
user_id: str
|
||||
|
||||
class TagExtractResponse(BaseModel):
|
||||
tags: list[TagProperties]
|
||||
tag_count: int
|
||||
|
||||
class TagRelationRequest(BaseModel):
|
||||
tag_paths: list[str] = Field(..., min_length=2)
|
||||
user_id: str
|
||||
|
||||
class RelatedContentRequest(BaseModel):
|
||||
tag_ids: list[str]
|
||||
user_id: str
|
||||
limit: int = 10
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add tag endpoints**
|
||||
|
||||
```python
|
||||
@router.post("/tags/extract", response_model=TagExtractResponse)
|
||||
async def extract_tags(request: TagExtractRequest, db: Session = Depends(get_db)):
|
||||
"""从内容中提取标签(不保存到节点)"""
|
||||
# 需要注入 LLM client
|
||||
from app.services.tag_service import TagService
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
llm_client = get_llm_client()
|
||||
tag_service = TagService(db, llm_client)
|
||||
|
||||
tag_infos = tag_service.extract_tags_from_content(request.content, request.user_id)
|
||||
tags = []
|
||||
for t in tag_infos:
|
||||
short_name, level, parent_path = tag_service.parse_tag_path(t["path"])
|
||||
tags.append(TagProperties(
|
||||
tag_path=t["path"],
|
||||
short_name=short_name,
|
||||
level=level,
|
||||
parent_path=parent_path,
|
||||
description=t.get("description")
|
||||
))
|
||||
|
||||
return TagExtractResponse(tags=tags, tag_count=len(tags))
|
||||
|
||||
@router.post("/tags/content/{node_id}", response_model=TagExtractResponse)
|
||||
async def tag_content_node(
|
||||
node_id: str,
|
||||
request: TagExtractRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""为内容节点打标签"""
|
||||
from app.services.tag_service import TagService
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
node = db.query(KGNode).filter(KGNode.id == node_id).first()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
llm_client = get_llm_client()
|
||||
tag_service = TagService(db, llm_client)
|
||||
|
||||
tag_nodes = tag_service.tag_content(request.content, request.user_id, node)
|
||||
tags = []
|
||||
for n in tag_nodes:
|
||||
props = n.properties_ or {}
|
||||
tags.append(TagProperties(
|
||||
tag_path=props.get("tag_path", n.name),
|
||||
short_name=n.name,
|
||||
level=props.get("level", 1),
|
||||
parent_path=props.get("parent_path"),
|
||||
description=n.description
|
||||
))
|
||||
|
||||
return TagExtractResponse(tags=tags, tag_count=len(tags))
|
||||
|
||||
@router.get("/tags/{user_id}", response_model=list[KGNodeOut])
|
||||
async def get_user_tags(user_id: str, db: Session = Depends(get_db)):
|
||||
"""获取用户的所有标签"""
|
||||
nodes = db.query(KGNode).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type == "tag"
|
||||
).order_by(KGNode.properties_["level"].astext).all()
|
||||
|
||||
return nodes
|
||||
|
||||
@router.get("/tags/{tag_id}/related", response_model=list[KGNodeOut])
|
||||
async def get_related_tags(tag_id: str, db: Session = Depends(get_db)):
|
||||
"""获取标签的关联标签"""
|
||||
# 获取该标签的所有关联边
|
||||
edges = db.query(KGEdge).filter(
|
||||
or_(KGEdge.source_id == tag_id, KGEdge.target_id == tag_id),
|
||||
KGEdge.relation_type.in_(["related_to", "synonym_of"])
|
||||
).all()
|
||||
|
||||
related_ids = set()
|
||||
for e in edges:
|
||||
if e.source_id == tag_id:
|
||||
related_ids.add(e.target_id)
|
||||
else:
|
||||
related_ids.add(e.source_id)
|
||||
|
||||
if not related_ids:
|
||||
return []
|
||||
|
||||
nodes = db.query(KGNode).filter(KGNode.id.in_(related_ids)).all()
|
||||
return nodes
|
||||
|
||||
@router.post("/content/related", response_model=list[KGNodeOut])
|
||||
async def get_related_content(
|
||||
request: RelatedContentRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""通过标签找相关内容"""
|
||||
from app.services.tag_service import TagService
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
llm_client = get_llm_client()
|
||||
tag_service = TagService(db, llm_client)
|
||||
|
||||
results = tag_service.get_related_content(request.tag_ids, request.user_id, request.limit)
|
||||
nodes = [r[0] for r in results]
|
||||
return nodes
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/routers/graph.py
|
||||
git commit -m "feat: add tag API routes for extraction and retrieval"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add has_tag Edge Creation
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/tag_service.py`
|
||||
|
||||
- [ ] **Step 1: Add has_tag edge when tagging content**
|
||||
|
||||
在 `tag_content` 方法中,添加创建 `has_tag` 边的逻辑:
|
||||
|
||||
```python
|
||||
# 在 `tag_content` 方法中,`更新内容节点的 tag_node_ids` 之前添加:
|
||||
|
||||
# 创建 has_tag 边
|
||||
for tag_node in tag_nodes:
|
||||
existing_edge = self.db.query(KGEdge).filter(
|
||||
KGEdge.source_id == content_node.id,
|
||||
KGEdge.target_id == tag_node.id,
|
||||
KGEdge.relation_type == "has_tag"
|
||||
).first()
|
||||
if not existing_edge:
|
||||
edge = KGEdge(
|
||||
source_id=content_node.id,
|
||||
target_id=tag_node.id,
|
||||
relation_type="has_tag",
|
||||
weight=1.0
|
||||
)
|
||||
self.db.add(edge)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/tag_service.py
|
||||
git commit -m "fix: create has_tag edges when tagging content"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Write Unit Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/backend/app/services/test_tag_service.py`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from app.services.tag_service import TagService
|
||||
|
||||
class TestTagService:
|
||||
def test_parse_tag_path_single_level(self):
|
||||
service = TagService(db=MagicMock(), llm_client=MagicMock())
|
||||
short_name, level, parent_path = service.parse_tag_path("Python")
|
||||
|
||||
assert short_name == "Python"
|
||||
assert level == 1
|
||||
assert parent_path is None
|
||||
|
||||
def test_parse_tag_path_nested(self):
|
||||
service = TagService(db=MagicMock(), llm_client=MagicMock())
|
||||
short_name, level, parent_path = service.parse_tag_path("编程语言/Python/异步")
|
||||
|
||||
assert short_name == "异步"
|
||||
assert level == 3
|
||||
assert parent_path == "编程语言/Python"
|
||||
|
||||
def test_parse_tag_path_strips_slashes(self):
|
||||
service = TagService(db=MagicMock(), llm_client=MagicMock())
|
||||
short_name, level, parent_path = service.parse_tag_path("/后端/框架/")
|
||||
|
||||
assert short_name == "框架"
|
||||
assert level == 2
|
||||
assert parent_path == "后端"
|
||||
|
||||
@patch('app.services.tag_service.KGNode')
|
||||
@patch('app.services.tag_service.KGEdge')
|
||||
def test_get_or_create_tag_node_creates_new(self, mock_edge, mock_node):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
service = TagService(db=mock_db, llm_client=MagicMock())
|
||||
tag_info = {"path": "Python", "description": "Python语言"}
|
||||
|
||||
result = service.get_or_create_tag_node(tag_info, "user_123")
|
||||
|
||||
assert result is not None
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.flush.assert_called_once()
|
||||
|
||||
@patch('app.services.tag_service.KGNode')
|
||||
def test_get_or_create_tag_node_returns_existing(self, mock_node):
|
||||
mock_db = MagicMock()
|
||||
mock_existing = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_existing
|
||||
|
||||
service = TagService(db=mock_db, llm_client=MagicMock())
|
||||
tag_info = {"path": "Python", "description": "Python语言"}
|
||||
|
||||
result = service.get_or_create_tag_node(tag_info, "user_123")
|
||||
|
||||
assert result == mock_existing
|
||||
mock_db.add.assert_not_called()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
cd backend && python -m pytest tests/backend/app/services/test_tag_service.py -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/backend/app/services/test_tag_service.py
|
||||
git commit -m "test: add unit tests for TagService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add Scheduled Tag Generation Job
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/scheduler_service.py:1-211`
|
||||
- Modify: `backend/app/routers/scheduler.py:1-41`
|
||||
|
||||
- [ ] **Step 1: Add async tag service method**
|
||||
|
||||
在 `TagService` 中添加增量打标签方法:
|
||||
|
||||
```python
|
||||
# 在 tag_service.py 添加
|
||||
|
||||
async def tag_incremental_content(self, user_id: str, days: int = 1) -> dict:
|
||||
"""
|
||||
增量打标签 - 只对最近新增/更新的内容节点打标签
|
||||
- days: 追溯最近多少天内的内容
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# 查找尚未打标签的内容节点(tag_node_ids 为空或未设置)
|
||||
content_nodes = self.db.query(KGNode).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type.in_(["conversation", "document", "chunk"]),
|
||||
KGNode.updated_at >= cutoff_date
|
||||
).all()
|
||||
|
||||
# 过滤掉已有标签的节点
|
||||
untagged = [
|
||||
n for n in content_nodes
|
||||
if not n.properties_.get("tag_node_ids")
|
||||
]
|
||||
|
||||
tagged_count = 0
|
||||
for node in untagged:
|
||||
content = node.description or ""
|
||||
try:
|
||||
self.tag_content(content, user_id, node)
|
||||
tagged_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"标签化节点 {node.id} 失败: {e}")
|
||||
|
||||
return {"total": len(untagged), "tagged": tagged_count}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add scheduled job function**
|
||||
|
||||
在 `scheduler_service.py` 中添加:
|
||||
|
||||
```python
|
||||
async def tag_generation_task():
|
||||
"""
|
||||
每日凌晨 00:00 增量标签生成任务
|
||||
- 扫描最近 24 小时新增/更新的内容
|
||||
- AI 自动提取标签
|
||||
- 建立标签关系网络
|
||||
"""
|
||||
logger.info("[Scheduler] 开始执行每日标签生成...")
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
from app.services.tag_service import TagService
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
llm_client = get_llm_client()
|
||||
tag_service = TagService(db, llm_client)
|
||||
|
||||
# 获取所有有内容的用户
|
||||
result = await db.execute(
|
||||
select(KGNode.user_id).distinct().where(
|
||||
KGNode.entity_type.in_(["conversation", "document", "chunk"])
|
||||
)
|
||||
)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
total_tagged = 0
|
||||
for user_id in user_ids:
|
||||
# 同步调用增量打标签
|
||||
sync_tag_service = TagService(db, llm_client)
|
||||
result = sync_tag_service.tag_incremental_content(user_id, days=1)
|
||||
total_tagged += result["tagged"]
|
||||
|
||||
logger.info(f"[Scheduler] 每日标签生成完成,共标签化 {total_tagged} 个内容节点")
|
||||
except Exception as e:
|
||||
logger.error(f"[Scheduler] 每日标签生成失败: {e}")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register the scheduled job**
|
||||
|
||||
在 `start_scheduler()` 函数中添加:
|
||||
|
||||
```python
|
||||
# 每天凌晨 00:00 生成标签
|
||||
scheduler.add_job(
|
||||
tag_generation_task,
|
||||
CronTrigger(hour=0, minute=0, timezone="Asia/Shanghai"),
|
||||
id="tag_generation",
|
||||
name="每日标签生成",
|
||||
replace_existing=True,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update scheduler router**
|
||||
|
||||
在 `routers/scheduler.py` 的 `job_map` 中添加:
|
||||
|
||||
```python
|
||||
job_map = {
|
||||
"daily_task_analysis": daily_task_analysis,
|
||||
"forum_scan": forum_scan_task,
|
||||
"graph_rebuild": graph_rebuild_task,
|
||||
"tag_generation": tag_generation_task, # 新增
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/scheduler_service.py backend/app/routers/scheduler.py
|
||||
git commit -m "feat: add daily scheduled tag generation at midnight"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | Files |
|
||||
|------|-------------|-------|
|
||||
| 1 | Extend KGNode for tag support | `models/knowledge_graph.py`, `schemas/graph.py` |
|
||||
| 2 | Create TagService for AI tag extraction | `services/tag_service.py` |
|
||||
| 3 | Add tag API routes | `routers/graph.py` |
|
||||
| 4 | Fix has_tag edge creation | `services/tag_service.py` |
|
||||
| 5 | Write unit tests | `tests/backend/app/services/test_tag_service.py` |
|
||||
| 6 | Add scheduled tag generation (00:00) | `services/scheduler_service.py`, `routers/scheduler.py` |
|
||||
1611
docs/superpowers/plans/2026-03-21-forum-redesign-implementation.md
Normal file
1611
docs/superpowers/plans/2026-03-21-forum-redesign-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
941
docs/superpowers/plans/2026-03-21-knowledge-folder-plan.md
Normal file
941
docs/superpowers/plans/2026-03-21-knowledge-folder-plan.md
Normal file
@@ -0,0 +1,941 @@
|
||||
# 知识库文件夹分层实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为知识库添加文件夹分层组织功能,支持多层嵌套、CRUD、级联删除
|
||||
|
||||
**Architecture:** 使用邻接表模式(parent_id)存储文件夹层级,通过递归 CTE 查询完整树结构。ChromaDB metadata 增加 folder_path 支持按文件夹过滤检索。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy async + SQLite + ChromaDB + Vue 3 + TypeScript
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 Folder 模型
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/folder.py`
|
||||
- Test: `backend/app/models/test_folder.py`
|
||||
|
||||
- [ ] **Step 1: 创建 Folder 模型**
|
||||
|
||||
```python
|
||||
# backend/app/models/folder.py
|
||||
from sqlalchemy import Column, String, ForeignKey, UniqueConstraint
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class Folder(BaseModel):
|
||||
__tablename__ = "folders"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'parent_id', 'name', name='uq_user_parent_name'),
|
||||
)
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
parent_id = Column(String(36), ForeignKey("folders.id"), nullable=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建测试文件验证模型**
|
||||
|
||||
```python
|
||||
# backend/app/models/test_folder.py
|
||||
import pytest
|
||||
from app.models.folder import Folder
|
||||
|
||||
def test_folder_model_creation():
|
||||
folder = Folder(user_id="test-user", name="Test Folder")
|
||||
assert folder.name == "Test Folder"
|
||||
assert folder.parent_id is None
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建 Folder Schema
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/folder.py`
|
||||
|
||||
- [ ] **Step 1: 创建 Pydantic schemas**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/folder.py
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v):
|
||||
forbidden = '/\\*?:'
|
||||
for c in forbidden:
|
||||
if c in v:
|
||||
raise ValueError(f'Folder name cannot contain: {forbidden}')
|
||||
return v
|
||||
|
||||
class FolderUpdate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
class FolderOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
parent_id: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class FolderTreeOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
parent_id: Optional[str]
|
||||
children: List["FolderTreeOut"] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
# 递归模型需要 forward ref
|
||||
FolderTreeOut.model_rebuild()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 创建文件夹路由
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/folder.py`
|
||||
|
||||
- [ ] **Step 1: 实现文件夹 CRUD 路由**
|
||||
|
||||
```python
|
||||
# backend/app/routers/folder.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models.folder import Folder
|
||||
from app.models.user import User
|
||||
from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
|
||||
|
||||
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
|
||||
"""递归构建文件夹树"""
|
||||
tree = []
|
||||
for folder in folders:
|
||||
if folder.parent_id == parent_id:
|
||||
children = build_folder_tree(folders, folder.id)
|
||||
tree.append(FolderTreeOut(
|
||||
id=folder.id,
|
||||
name=folder.name,
|
||||
parent_id=folder.parent_id,
|
||||
children=children
|
||||
))
|
||||
return tree
|
||||
|
||||
@router.get("", response_model=List[FolderTreeOut])
|
||||
async def get_folders(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""获取用户的完整文件夹树"""
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.user_id == current_user.id)
|
||||
)
|
||||
folders = result.scalars().all()
|
||||
return build_folder_tree(list(folders))
|
||||
|
||||
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_folder(
|
||||
folder_data: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""创建文件夹"""
|
||||
# 验证父文件夹存在且属于当前用户
|
||||
if folder_data.parent_id:
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_data.parent_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="父文件夹不存在")
|
||||
|
||||
# 检查同名文件夹
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.parent_id == folder_data.parent_id,
|
||||
Folder.name == folder_data.name
|
||||
)
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="同名文件夹已存在")
|
||||
|
||||
folder = Folder(
|
||||
user_id=current_user.id,
|
||||
name=folder_data.name,
|
||||
parent_id=folder_data.parent_id
|
||||
)
|
||||
db.add(folder)
|
||||
await db.commit()
|
||||
await db.refresh(folder)
|
||||
return folder
|
||||
|
||||
@router.put("/{folder_id}", response_model=FolderOut)
|
||||
async def rename_folder(
|
||||
folder_id: str,
|
||||
folder_data: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""重命名文件夹"""
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
|
||||
folder.name = folder_data.name
|
||||
await db.commit()
|
||||
await db.refresh(folder)
|
||||
return folder
|
||||
|
||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""删除文件夹(级联删除文档)"""
|
||||
from app.models.document import Document
|
||||
from app.services.knowledge_service import KnowledgeService
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
|
||||
async def delete_recursive(fid: str):
|
||||
# 删除子文件夹(先递归)
|
||||
children = await db.execute(
|
||||
select(Folder).where(Folder.parent_id == fid)
|
||||
)
|
||||
for child in children.scalars():
|
||||
await delete_recursive(child.id)
|
||||
|
||||
# 删除文档
|
||||
docs = await db.execute(
|
||||
select(Document).where(Document.folder_id == fid)
|
||||
)
|
||||
for doc in docs.scalars():
|
||||
knowledge_service = KnowledgeService(db, current_user.id)
|
||||
await knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
|
||||
await db.delete(doc)
|
||||
|
||||
# 删除文件夹本身
|
||||
folder_to_delete = await db.get(Folder, fid)
|
||||
if folder_to_delete:
|
||||
await db.delete(folder_to_delete)
|
||||
|
||||
await delete_recursive(folder_id)
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 修改 Document 模型
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/document.py:14`
|
||||
|
||||
- [ ] **Step 1: 添加 folder_id 外键**
|
||||
|
||||
```python
|
||||
# backend/app/models/document.py 第14行后添加
|
||||
folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # 新增
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 修改 Document 路由和服务
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/document.py`
|
||||
- Modify: `backend/app/services/document_service.py`
|
||||
|
||||
- [ ] **Step 1: 修改 Document 路由**
|
||||
|
||||
在 `routers/document.py` 中:
|
||||
- GET `/api/documents` 添加 `folder_id` 可选查询参数
|
||||
- POST `/api/documents` 添加 `folder_id` 表单字段
|
||||
|
||||
```python
|
||||
# GET /api/documents 修改
|
||||
@router.get("")
|
||||
async def list_documents(
|
||||
folder_id: Optional[str] = None, # 新增
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
query = select(Document).where(Document.user_id == current_user.id)
|
||||
if folder_id:
|
||||
query = query.where(Document.folder_id == folder_id)
|
||||
# ... 其余不变
|
||||
|
||||
# POST /api/documents 修改
|
||||
@router.post("")
|
||||
async def upload_document(
|
||||
file: UploadFile = File(...),
|
||||
folder_id: Optional[str] = Form(None), # 新增
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# ... 文件处理逻辑 ...
|
||||
doc = await doc_svc.upload_document(
|
||||
user_id=current_user.id,
|
||||
file=file,
|
||||
folder_id=folder_id # 传递 folder_id
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 DocumentService.upload_document**
|
||||
|
||||
```python
|
||||
# backend/app/services/document_service.py
|
||||
|
||||
async def upload_document(
|
||||
self,
|
||||
user_id: str,
|
||||
file: UploadFile,
|
||||
folder_id: str | None = None, # 新增
|
||||
) -> Document:
|
||||
# ... 文件保存逻辑 ...
|
||||
|
||||
# 获取文件夹路径(用于 ChromaDB metadata)
|
||||
folder_path = None
|
||||
if folder_id:
|
||||
folder_path = await self._get_folder_path(folder_id)
|
||||
|
||||
# 创建文档记录
|
||||
doc = Document(
|
||||
user_id=user_id,
|
||||
title=filename.rsplit('.', 1)[0],
|
||||
filename=filename,
|
||||
file_type=file_type,
|
||||
file_size=file_size,
|
||||
file_path=file_path,
|
||||
folder_id=folder_id, # 新增
|
||||
)
|
||||
# ... 其余逻辑 ...
|
||||
return doc
|
||||
|
||||
async def _get_folder_path(self, folder_id: str) -> str | None:
|
||||
"""获取文件夹的完整路径"""
|
||||
folders = await self.db.execute(
|
||||
select(Folder).where(Folder.user_id == self.user_id)
|
||||
)
|
||||
folder_map = {f.id: f for f in folders.scalars().all()}
|
||||
|
||||
path_parts = []
|
||||
current_id = folder_id
|
||||
while current_id:
|
||||
folder = folder_map.get(current_id)
|
||||
if not folder:
|
||||
break
|
||||
path_parts.insert(0, folder.name)
|
||||
current_id = folder.parent_id
|
||||
|
||||
return "/" + "/".join(path_parts) if path_parts else None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 修改 Knowledge Service
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/knowledge_service.py`
|
||||
|
||||
- [ ] **Step 1: retrieve 方法添加 folder_id 参数**
|
||||
|
||||
```python
|
||||
# backend/app/services/knowledge_service.py
|
||||
|
||||
# 在 index_document 方法中添加 folder_path 到 metadata
|
||||
async def index_document(self, document_id: str, user_id: str, folder_path: str | None = None):
|
||||
"""将文档 chunks 向量化存入 ChromaDB"""
|
||||
# ... 现有代码 ...
|
||||
|
||||
metadatas = [
|
||||
{
|
||||
"document_id": doc.id,
|
||||
"document_title": doc.title,
|
||||
"chunk_index": chunk.chunk_index,
|
||||
"file_type": doc.file_type,
|
||||
"folder_path": folder_path or "", # 新增
|
||||
}
|
||||
for chunk in chunks
|
||||
]
|
||||
# ... 其余不变
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
folder_id: str | None = None, # 新增
|
||||
top_k: int = 5,
|
||||
use_rerank: bool = True,
|
||||
) -> list[SearchResult]:
|
||||
"""混合检索 + Rerank,支持按文件夹过滤"""
|
||||
collection = self.get_collection(user_id)
|
||||
|
||||
# 构建过滤条件
|
||||
where = None
|
||||
if folder_id:
|
||||
folder_path = await self._get_folder_path(folder_id)
|
||||
if folder_path:
|
||||
where = {"folder_path": {"$starts_with": folder_path}}
|
||||
|
||||
try:
|
||||
results = collection.query(
|
||||
query_texts=[query],
|
||||
n_results=top_k * 3,
|
||||
where=where,
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
# ... 其余不变
|
||||
|
||||
async def _get_folder_path(self, folder_id: str) -> str | None:
|
||||
"""获取文件夹的完整路径"""
|
||||
result = await self.db.execute(
|
||||
select(Folder).where(Folder.id == folder_id)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
return None
|
||||
|
||||
path_parts = [folder.name]
|
||||
current_parent_id = folder.parent_id
|
||||
|
||||
while current_parent_id:
|
||||
parent_result = await self.db.execute(
|
||||
select(Folder).where(Folder.id == current_parent_id)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
break
|
||||
path_parts.insert(0, parent.name)
|
||||
current_parent_id = parent.parent_id
|
||||
|
||||
return "/" + "/".join(path_parts)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 数据库迁移
|
||||
|
||||
**Files:**
|
||||
- 数据库操作: 添加 folders 表,添加 documents.folder_id 列
|
||||
|
||||
- [ ] **Step 1: 执行 SQL 迁移**
|
||||
|
||||
```python
|
||||
# 迁移脚本
|
||||
import asyncio
|
||||
from app.database import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
async def migrate():
|
||||
async with engine.begin() as conn:
|
||||
# 创建 folders 表
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id VARCHAR(36),
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES folders(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# 添加 documents.folder_id 列
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE documents ADD COLUMN folder_id VARCHAR(36)"))
|
||||
except Exception:
|
||||
pass # 列已存在
|
||||
|
||||
asyncio.run(migrate())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 前端 - 创建 Folder API
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/folder.ts`
|
||||
|
||||
- [ ] **Step 1: 创建文件夹 API 客户端**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/folder.ts
|
||||
import api from './index'
|
||||
|
||||
export interface FolderCreate {
|
||||
name: string
|
||||
parent_id?: string | null
|
||||
}
|
||||
|
||||
export interface FolderUpdate {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface FolderItem {
|
||||
id: string
|
||||
name: string
|
||||
parent_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FolderTree {
|
||||
id: string
|
||||
name: string
|
||||
parent_id: string | null
|
||||
children: FolderTree[]
|
||||
}
|
||||
|
||||
export const folderApi = {
|
||||
// 获取文件夹树
|
||||
getTree() {
|
||||
return api.get<FolderTree[]>('/api/folders')
|
||||
},
|
||||
|
||||
// 创建文件夹
|
||||
create(data: FolderCreate) {
|
||||
return api.post('/api/folders', data)
|
||||
},
|
||||
|
||||
// 重命名文件夹
|
||||
rename(id: string, data: FolderUpdate) {
|
||||
return api.put(`/api/folders/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除文件夹
|
||||
delete(id: string) {
|
||||
return api.delete(`/api/folders/${id}`)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 前端 - 修改 Document API
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/document.ts`
|
||||
|
||||
- [ ] **Step 1: 添加 folder_id 到 Document 类型**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/document.ts
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
title: string
|
||||
filename: string
|
||||
file_type: string
|
||||
file_size: number
|
||||
file_path: string
|
||||
summary?: string
|
||||
chunk_count: number
|
||||
is_indexed: boolean
|
||||
folder_id?: string | null // 新增
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 10: 前端 - 创建 FolderTree 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/FolderTree.vue`
|
||||
|
||||
- [ ] **Step 1: 创建递归文件夹树组件**
|
||||
|
||||
```vue
|
||||
<!-- frontend/src/components/FolderTree.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { folderApi, type FolderTree } from '@/api/folder'
|
||||
import { Folder, FolderOpen, ChevronRight, MoreVertical, Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
folders: FolderTree[]
|
||||
selectedId?: string | null
|
||||
onSelect: (folder: FolderTree) => void
|
||||
onCreate: (parentId: string | null) => void
|
||||
onRename: (folder: FolderTree) => void
|
||||
onDelete: (folder: FolderTree) => void
|
||||
}>()
|
||||
|
||||
const expandedIds = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
expandedIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
|
||||
e.preventDefault()
|
||||
// 显示右键菜单
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="folder-tree">
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
class="folder-item"
|
||||
>
|
||||
<div
|
||||
class="folder-row"
|
||||
:class="{ selected: folder.id === selectedId }"
|
||||
@click="props.onSelect(folder)"
|
||||
@contextmenu="handleContextMenu($event, folder)"
|
||||
>
|
||||
<!-- 展开/折叠箭头 -->
|
||||
<button
|
||||
v-if="folder.children?.length"
|
||||
class="expand-btn"
|
||||
@click.stop="toggleExpand(folder.id)"
|
||||
>
|
||||
<ChevronRight
|
||||
:size="12"
|
||||
:class="{ rotated: expandedIds.has(folder.id) }"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="expand-placeholder"></span>
|
||||
|
||||
<!-- 文件夹图标 -->
|
||||
<FolderOpen v-if="expandedIds.has(folder.id)" :size="14" class="folder-icon" />
|
||||
<Folder v-else :size="14" class="folder-icon" />
|
||||
|
||||
<!-- 文件夹名称 -->
|
||||
<span class="folder-name">{{ folder.name }}</span>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="folder-actions">
|
||||
<button @click.stop="props.onCreate(folder.id)" title="添加子文件夹">
|
||||
<Plus :size="12" />
|
||||
</button>
|
||||
<button @click.stop="props.onRename(folder)" title="重命名">
|
||||
<Edit2 :size="12" />
|
||||
</button>
|
||||
<button @click.stop="props.onDelete(folder)" title="删除">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子文件夹(递归) -->
|
||||
<div
|
||||
v-if="folder.children?.length && expandedIds.has(folder.id)"
|
||||
class="folder-children"
|
||||
>
|
||||
<FolderTree
|
||||
:folders="folder.children"
|
||||
:selected-id="selectedId"
|
||||
:on-select="onSelect"
|
||||
:on-create="onCreate"
|
||||
:on-rename="onRename"
|
||||
:on-delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* sci-fi 风格 */
|
||||
.folder-tree {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.folder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.folder-row:hover {
|
||||
background: rgba(0, 245, 212, 0.04);
|
||||
}
|
||||
|
||||
.folder-row.selected {
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expand-placeholder {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
color: var(--accent-amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.folder-row:hover .folder-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.folder-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
border-radius: 3px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.folder-actions button:hover {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
}
|
||||
|
||||
.folder-children {
|
||||
padding-left: 16px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 前端 - 改造 KnowledgeView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
||||
|
||||
- [ ] **Step 1: 添加文件夹侧边栏和交互逻辑**
|
||||
|
||||
主要改动:
|
||||
- 导入 FolderTree 组件
|
||||
- 添加文件夹状态和加载逻辑
|
||||
- 修改上传逻辑(需先选择文件夹)
|
||||
- 添加新建/重命名/删除文件夹的弹窗
|
||||
|
||||
```vue
|
||||
<!-- KnowledgeView.vue 核心逻辑改动 -->
|
||||
<script setup lang="ts">
|
||||
// ... 现有代码 ...
|
||||
|
||||
// 新增
|
||||
import { folderApi, type FolderTree, type FolderCreate } from '@/api/folder'
|
||||
import FolderTreeComponent from '@/components/FolderTree.vue'
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
// 状态
|
||||
const folders = ref<FolderTree[]>([])
|
||||
const selectedFolderId = ref<string | null>(null)
|
||||
|
||||
// 加载文件夹树
|
||||
async function loadFolders() {
|
||||
const res = await folderApi.getTree()
|
||||
folders.value = res.data
|
||||
}
|
||||
|
||||
// 选择文件夹
|
||||
function onSelectFolder(folder: FolderTree) {
|
||||
selectedFolderId.value = folder.id
|
||||
loadDocumentsByFolder(folder.id)
|
||||
}
|
||||
|
||||
// 加载指定文件夹的文档
|
||||
async function loadDocumentsByFolder(folderId: string) {
|
||||
const res = await documentApi.list(folderId)
|
||||
documents.value = res.data
|
||||
}
|
||||
|
||||
// 新建文件夹弹窗
|
||||
const showNewFolderDialog = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderParentId = ref<string | null>(null)
|
||||
|
||||
function openNewFolderDialog(parentId: string | null = null) {
|
||||
newFolderParentId.value = parentId
|
||||
newFolderName.value = ''
|
||||
showNewFolderDialog.value = true
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
await folderApi.create({
|
||||
name: newFolderName.value,
|
||||
parent_id: newFolderParentId.value
|
||||
})
|
||||
await loadFolders()
|
||||
showNewFolderDialog.value = false
|
||||
}
|
||||
|
||||
// 重命名/删除类似...
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="knowledge-view">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<Database :size="20" />
|
||||
<h1>KNOWLEDGE BASE</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn" @click="openNewFolderDialog(null)">
|
||||
<FolderPlus :size="14" /> 新建文件夹
|
||||
</button>
|
||||
<button class="btn primary" @click="triggerUpload">
|
||||
<Upload :size="14" /> 上传文档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主布局:侧边栏 + 内容 -->
|
||||
<div class="main-layout">
|
||||
<!-- 左侧文件夹树 -->
|
||||
<aside class="folder-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<Folder :size="14" />
|
||||
<span>文件夹</span>
|
||||
<button class="add-btn" @click="openNewFolderDialog(null)">
|
||||
<Plus :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="folder-list">
|
||||
<FolderTreeComponent
|
||||
:folders="folders"
|
||||
:selected-id="selectedFolderId"
|
||||
:on-select="onSelectFolder"
|
||||
:on-create="openNewFolderDialog"
|
||||
:on-rename="openRenameDialog"
|
||||
:on-delete="openDeleteDialog"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<main class="content-area">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-panel">...</div>
|
||||
|
||||
<!-- 上传区 -->
|
||||
<div class="upload-zone">...</div>
|
||||
|
||||
<!-- 文档列表 -->
|
||||
<div class="docs-section">...</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 新建文件夹弹窗 -->
|
||||
<div v-if="showNewFolderDialog" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h3>新建文件夹</h3>
|
||||
<input v-model="newFolderName" placeholder="文件夹名称" @keyup.enter="createFolder" />
|
||||
<div class="dialog-actions">
|
||||
<button @click="showNewFolderDialog = false">取消</button>
|
||||
<button class="primary" @click="createFolder">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 12: 集成测试
|
||||
|
||||
- [ ] **Step 1: 测试文件夹 CRUD**
|
||||
- [ ] **Step 2: 测试级联删除**
|
||||
- [ ] **Step 3: 测试上传文档到指定文件夹**
|
||||
- [ ] **Step 4: 测试按文件夹搜索**
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
共 12 个 Task,分 4 个 Phase:
|
||||
- **Phase 1**: 数据层 (Task 1-4)
|
||||
- **Phase 2**: 后端 API (Task 5-7)
|
||||
- **Phase 3**: 前端 (Task 8-11)
|
||||
- **Phase 4**: 测试 (Task 12)
|
||||
Reference in New Issue
Block a user