16 KiB
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
<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
<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
// 在 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 方法
// 新增方法
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 后添加:
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: 添加文件上传方法
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:
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 按钮
在输入框的按钮区域添加:
<!-- 文件选择 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 部分添加:
/* 文件消息样式 */
.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
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 模型
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 接口
@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 方法
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 方法中:
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 检查
cd frontend && npx vue-tsc --noEmit
- Step 2: 后端语法检查
cd backend && python -m py_compile app/routers/conversation.py app/services/agent_service.py app/services/document_service.py
- Step 3: 启动服务测试
# 后端
cd backend && python -m uvicorn app.main:app --reload
# 前端
cd frontend && npm run dev
执行选项
1. Subagent-Driven (推荐) - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代
2. Inline Execution - 在当前会话中按批次执行任务
选择哪种方式?