Files
JARVIS/docs/superpowers/plans/2026-03-20-chat-enhancement-implementation.md

712 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_idsAgentService 读取文件内容作为上下文
**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** - 在当前会话中按批次执行任务
选择哪种方式?