ai-courseware/.trae/documents/04-AI实战演示与落地实施篇.md

15 KiB
Raw Blame History

用户界面层 ├── Web界面Streamlit构建的交互界面 ├── API接口FastAPI提供的RESTful API └── 管理后台Flask构建的管理界面

业务逻辑层 ├── 问答引擎LangChain核心逻辑 ├── 文档处理:文档解析和向量化 ├── 检索系统:向量检索和重排序 └── 对话管理:多轮对话状态管理

数据存储层 ├── 向量数据库ChromaDB存储文档向量 ├── 关系数据库PostgreSQL存储元数据 ├── 文件存储MinIO存储原始文档 └── 缓存系统Redis缓存热点数据

模型服务层 ├── 嵌入模型text-embedding-ada-002 ├── 大语言模型gpt-3.5-turbo ├── 重排序模型bge-reranker-base └── 摘要模型gpt-3.5-turbo

前端架构设计

技术栈选择:

前端框架:
├── Vue 3.3组合式API更好的TypeScript支持
├── TypeScript 5类型安全开发体验好
├── Vite 4快速的构建工具开发体验极佳
└── Vue Router 4路由管理支持历史模式

UI组件库
├── Element Plus企业级UI组件库
├── Tailwind CSS实用优先的CSS框架
├── Iconify丰富的图标库
└── VueUse实用的Vue组合式函数库

AI交互
├── SSE支持Server-Sent Events实时通信
├── Markdown渲染支持富文本展示
├── 代码高亮Prism.js代码语法高亮
└── 文件上传:支持拖拽上传和进度显示

状态管理:
├── PiniaVue官方状态管理库
├── LocalStorage本地数据持久化
├── Session管理用户会话状态管理
└── 缓存策略智能的API响应缓存

核心界面实现:

主聊天界面

<!-- ChatInterface.vue -->
<template>
  <div class="chat-interface">
    <!-- 头部导航 -->
    <div class="chat-header">
      <div class="header-left">
        <el-icon class="header-icon"><ChatDotRound /></el-icon>
        <span class="header-title">智能文库助手</span>
      </div>
      <div class="header-right">
        <el-button
          type="text"
          :icon="Delete"
          @click="clearChat"
          class="clear-btn"
        >
          清空对话
        </el-button>
      </div>
    </div>

    <!-- 聊天消息区域 -->
    <div class="chat-messages" ref="messagesContainer">
      <div
        v-for="(message, index) in messages"
        :key="index"
        :class="['message-wrapper', message.role]"
      >
        <!-- 用户消息 -->
        <div v-if="message.role === 'user'" class="user-message">
          <div class="message-content">
            <div class="message-text">{{ message.content }}</div>
            <div class="message-time">{{ formatTime(message.timestamp) }}</div>
          </div>
          <div class="message-avatar">
            <el-icon><UserFilled /></el-icon>
          </div>
        </div>

        <!-- AI回复消息 -->
        <div v-else class="ai-message">
          <div class="message-avatar">
            <el-icon><ChatLineRound /></el-icon>
          </div>
          <div class="message-content">
            <!-- 消息文本 -->
            <div class="message-text" v-html="renderMarkdown(message.content)"></div>
            
            <!-- 加载状态 -->
            <div v-if="message.isLoading" class="loading-indicator">
              <el-icon class="is-loading"><Loading /></el-icon>
              <span>正在思考中...</span>
            </div>
            
            <!-- 错误状态 -->
            <div v-if="message.error" class="error-message">
              <el-icon><Warning /></el-icon>
              <span>{{ message.error }}</span>
            </div>
            
            <!-- 消息来源 -->
            <div v-if="message.sources && message.sources.length > 0" class="message-sources">
              <div class="sources-title">参考文档:</div>
              <div
                v-for="(source, sourceIndex) in message.sources"
                :key="sourceIndex"
                class="source-item"
                @click="viewSource(source)"
              >
                <el-icon><Document /></el-icon>
                <span class="source-name">{{ source.name }}</span>
                <span class="source-score">相似度: {{ (source.score * 100).toFixed(1) }}%</span>
              </div>
            </div>
            
            <!-- 消息时间 -->
            <div class="message-time">{{ formatTime(message.timestamp) }}</div>
          </div>
        </div>
      </div>
    </div>

    <!-- 输入区域 -->
    <div class="chat-input">
      <div class="input-wrapper">
        <el-input
          v-model="inputMessage"
          type="textarea"
          :rows="2"
          placeholder="请输入您的问题..."
          class="message-input"
          @keydown="handleKeydown"
          :disabled="isSending"
        />
        <div class="input-actions">
          <el-upload
            class="file-upload"
            action="/api/documents/upload"
            :show-file-list="false"
            :on-success="handleUploadSuccess"
            :before-upload="beforeUpload"
            accept=".pdf,.txt,.doc,.docx"
          >
            <el-button type="text" :icon="Paperclip" :disabled="isSending">
              上传文档
            </el-button>
          </el-upload>
          <el-button
            type="primary"
            :icon="Position"
            @click="sendMessage"
            :loading="isSending"
            :disabled="!inputMessage.trim()"
            class="send-button"
          >
            发送
          </el-button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { 
  ChatDotRound, Delete, UserFilled, ChatLineRound, 
  Loading, Warning, Document, Paperclip, Position 
} from '@element-plus/icons-vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'

// 消息接口
interface Message {
  role: 'user' | 'assistant'
  content: string
  timestamp: Date
  isLoading?: boolean
  error?: string
  sources?: DocumentSource[]
}

// 文档来源接口
interface DocumentSource {
  id: string
  name: string
  score: number
  content?: string
}

// 响应式数据
const messages = ref<Message[]>([])
const inputMessage = ref('')
const isSending = ref(false)
const messagesContainer = ref<HTMLElement>()

// 初始化marked配置
marked.setOptions({
  highlight: function(code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang }).value
    }
    return hljs.highlightAuto(code).value
  },
  langPrefix: 'hljs language-',
  breaks: true,
  gfm: true
})

// Markdown渲染函数
const renderMarkdown = (content: string): string => {
  const rawHtml = marked(content)
  const cleanHtml = DOMPurify.sanitize(rawHtml)
  return cleanHtml
}

// 格式化时间
const formatTime = (timestamp: Date): string => {
  const now = new Date()
  const diff = now.getTime() - timestamp.getTime()
  const minutes = Math.floor(diff / 60000)
  
  if (minutes < 1) return '刚刚'
  if (minutes < 60) return `${minutes}分钟前`
  
  const hours = Math.floor(minutes / 60)
  if (hours < 24) return `${hours}小时前`
  
  return timestamp.toLocaleString()
}

// 发送消息
const sendMessage = async () => {
  if (!inputMessage.value.trim() || isSending.value) return
  
  const userMessage = inputMessage.value.trim()
  inputMessage.value = ''
  
  // 添加用户消息
  messages.value.push({
    role: 'user',
    content: userMessage,
    timestamp: new Date()
  })
  
  // 添加AI回复占位
  const aiMessage: Message = {
    role: 'assistant',
    content: '',
    timestamp: new Date(),
    isLoading: true
  }
  messages.value.push(aiMessage)
  
  isSending.value = true
  
  try {
    // 调用AI服务
    const response = await fetch('/api/chat/stream', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        message: userMessage,
        history: messages.value.slice(-10).map(msg => ({
          role: msg.role,
          content: msg.content
        }))
      })
    })
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }
    
    // 处理SSE流式响应
    const reader = response.body?.getReader()
    const decoder = new TextDecoder()
    let aiContent = ''
    let sources: DocumentSource[] = []
    
    if (reader) {
      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        
        const chunk = decoder.decode(value)
        const lines = chunk.split('\n')
        
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6)
            if (data === '[DONE]') {
              aiMessage.isLoading = false
              break
            }
            
            try {
              const parsed = JSON.parse(data)
              if (parsed.type === 'content') {
                aiContent += parsed.content
                aiMessage.content = aiContent
              } else if (parsed.type === 'sources') {
                sources = parsed.sources
                aiMessage.sources = sources
              }
            } catch (e) {
              console.error('Parse error:', e)
            }
          }
        }
        
        // 滚动到底部
        await nextTick()
        scrollToBottom()
      }
    }
    
  } catch (error) {
    console.error('Chat error:', error)
    aiMessage.error = error instanceof Error ? error.message : '发送消息失败'
    aiMessage.isLoading = false
  } finally {
    isSending.value = false
  }
}

// 处理键盘事件
const handleKeydown = (event: KeyboardEvent) => {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault()
    sendMessage()
  }
}

// 滚动到底部
const scrollToBottom = () => {
  nextTick(() => {
    if (messagesContainer.value) {
      messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
    }
  })
}

// 清空对话
const clearChat = () => {
  messages.value = []
  ElMessage.success('对话已清空')
}

// 查看文档源
const viewSource = (source: DocumentSource) => {
  // 实现查看文档详情的逻辑
  console.log('View source:', source)
}

// 文件上传处理
const beforeUpload = (file: File) => {
  const isAllowedType = ['application/pdf', 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'].includes(file.type)
  const isLt10M = file.size / 1024 / 1024 < 10

  if (!isAllowedType) {
    ElMessage.error('只能上传 PDF、TXT、Word 文档!')
    return false
  }
  if (!isLt10M) {
    ElMessage.error('文档大小不能超过 10MB!')
    return false
  }
  return true
}

const handleUploadSuccess = (response: any) => {
  ElMessage.success('文档上传成功')
  // 可以添加刷新文档列表等逻辑
}

// 初始化
onMounted(() => {
  // 添加欢迎消息
  messages.value.push({
    role: 'assistant',
    content: '您好!我是智能文库助手,可以帮助您快速查找公司文档中的信息。请问有什么可以帮助您的吗?',
    timestamp: new Date()
  })
})
</script>

<style scoped>
.chat-interface {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: #f5f5f5;
}

.chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 24px;
  background-color: #fff;
  border-bottom: 1px solid #e0e0e0;
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}

.header-left {
  display: flex;
  align-items: center;
  gap: 12px;
}

.header-icon {
  font-size: 24px;
  color: #409eff;
}

.header-title {
  font-size: 18px;
  font-weight: 500;
  color: #303133;
}

.header-right {
  display: flex;
  align-items: center;
}

.clear-btn {
  color: #909399;
}

.clear-btn:hover {
  color: #409eff;
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 24px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.message-wrapper {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.message-wrapper.user {
  align-items: flex-end;
}

.message-wrapper.assistant {
  align-items: flex-start;
}

.user-message {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  max-width: 70%;
}

.ai-message {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  max-width: 70%;
}

.message-avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background-color: #409eff;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 18px;
  flex-shrink: 0;
}

.message-wrapper.assistant .message-avatar {
  background-color: #67c23a;
}

.message-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.message-text {
  background-color: #fff;
  padding: 12px 16px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  line-height: 1.6;
  word-wrap: break-word;
}

.user-message .message-text {
  background-color: #409eff;
  color: white;
}

.message-time {
  font-size: 12px;
  color: #909399;
  margin-top: 4px;
}

.loading-indicator {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #909399;
  font-size: 14px;
  margin-top: 8px;
}

.error-message {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #f56c6c;
  font-size: 14px;
  margin-top: 8px;
}

.message-sources {
  margin-top: 12px;
  padding: 12px;
  background-color: #f5f7fa;
  border-radius: 6px;
  border-left: 4px solid #409eff;
}

.sources-title {
  font-size: 14px;
  font-weight: 500;
  color: #303133;
  margin-bottom: 8px;
}

.source-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  background-color: white;
  border-radius: 4px;
  margin-bottom: 6px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.source-item:hover {
  background-color: #ecf5ff;
  transform: translateX(4px);
}

.source-item:last-child {
  margin-bottom: 0;
}

.source-name {
  flex: 1;
  font-size: 14px;
  color: #303133;
}

.source-score {
  font-size: 12px;
  color: #67c23a;
  font-weight: 500;
}

.chat-input {
  padding: 24px;
  background-color: #fff;
  border-top: 1px solid #e0e0e0;
}

.input-wrapper {
  max-width: 800px;
  margin: 0 auto;
}

.message-input {
  margin-bottom: 12px;
}

.message-input :deep(textarea) {
  font-size: 16px;
  line-height: 1