712 lines
16 KiB
Markdown
712 lines
16 KiB
Markdown
# 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** - 在当前会话中按批次执行任务
|
||
|
||
选择哪种方式?
|