# 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** - 在当前会话中按批次执行任务

选择哪种方式？
