620 lines
15 KiB
Markdown
620 lines
15 KiB
Markdown
用户界面层
|
||
├── 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代码语法高亮
|
||
└── 文件上传:支持拖拽上传和进度显示
|
||
|
||
状态管理:
|
||
├── Pinia:Vue官方状态管理库
|
||
├── LocalStorage:本地数据持久化
|
||
├── Session管理:用户会话状态管理
|
||
└── 缓存策略:智能的API响应缓存
|
||
```
|
||
|
||
**核心界面实现:**
|
||
|
||
#### 主聊天界面
|
||
```vue
|
||
<!-- 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
|