前端文档管理与智能问答界面开发

在上一篇文章中,我们完成了RAG系统的核心后端开发,集成了Chroma向量数据库,实现了文档向量化、存储和检索功能,并开发了OpenClaw RAG检索技能。这篇文章,我们将开发完整的前端用户界面,包括文档上传、文档管理和智能问答功能,让普通用户也能方便地使用我们的知识库系统。

摘要

  • 优化React前端项目架构,实现API封装和状态管理
  • 开发文档上传页面,支持拖拽上传、批量上传和进度显示
  • 实现文档管理页面,支持文档列表展示、搜索和删除
  • 开发智能问答界面,实现流式输出(打字机效果)和引用来源展示
  • 完成前后端联调,实现完整的"上传→解析→向量化→问答"用户流程

1. 前端项目架构优化

在开始开发具体功能之前,我们先优化一下前端项目的架构,使其更易于维护和扩展。

1.1 完善项目目录结构

我们将按照功能模块来组织代码,创建以下目录:

cd frontend
mkdir -p src/api src/components src/pages src/store src/utils

最终的前端目录结构将是:

frontend/
├── src/
│   ├── api/          # API 接口封装
│   ├── components/   # 通用组件
│   ├── pages/        # 页面组件
│   ├── store/        # 状态管理
│   ├── utils/        # 工具函数
│   ├── App.tsx
│   └── main.tsx
├── index.html
├── package.json
└── tsconfig.json

1.2 API 接口封装

创建 src/api/index.ts,封装所有后端 API 接口:

import axios from 'axios';

const API_BASE_URL = 'http://localhost:8000/api';
const OPENCLAW_BASE_URL = 'http://localhost:18789/api';

// 创建 axios 实例
const api = axios.create({
  baseURL: API_BASE_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
});

const openclawApi = axios.create({
  baseURL: OPENCLAW_BASE_URL,
  timeout: 60000,
});

// 文档相关接口
export const documentApi = {
  // 上传文档
  upload: (file: File, onProgress?: (progress: number) => void) => {
    const formData = new FormData();
    formData.append('file', file);
    
    return api.post('/documents/upload', formData, {
      headers: {
        'Content-Type': 'multipart/form-data',
      },
      onUploadProgress: (progressEvent) => {
        if (onProgress && progressEvent.total) {
          const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
          onProgress(progress);
        }
      },
    });
  },
  
  // 获取文档列表
  getList: () => api.get('/documents'),
  
  // 删除文档
  delete: (documentId: string) => api.delete(`/documents/${documentId}`),
};

// 向量相关接口
export const vectorApi = {
  // 搜索文档
  search: (query: string, topK: number = 5) => 
    api.post('/vector/search', { query, top_k: topK }),
};

// OpenClaw 相关接口
export const openclawApiService = {
  // 创建会话
  createSession: (title: string = '新会话') => 
    openclawApi.post('/sessions', { title }),
  
  // 发送消息
  sendMessage: (sessionId: string, content: string, stream: boolean = false) => 
    openclawApi.post(`/sessions/${sessionId}/messages`, {
      role: 'user',
      content,
      stream,
    }),
  
  // 获取会话历史
  getSessionHistory: (sessionId: string) => 
    openclawApi.get(`/sessions/${sessionId}/messages`),
};

export default api;

1.3 状态管理配置

我们使用 Zustand 进行状态管理,创建 src/store/index.ts

import { create } from 'zustand';

interface Message {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  sources?: any[];
  timestamp: Date;
}

interface AppState {
  // 会话状态
  sessionId: string | null;
  messages: Message[];
  isLoading: boolean;
  
  // 文档状态
  documents: any[];
  isUploading: boolean;
  uploadProgress: number;
  
  // 方法
  setSessionId: (id: string | null) => void;
  addMessage: (message: Message) => void;
  setMessages: (messages: Message[]) => void;
  setIsLoading: (loading: boolean) => void;
  setDocuments: (documents: any[]) => void;
  setIsUploading: (uploading: boolean) => void;
  setUploadProgress: (progress: number) => void;
  clearMessages: () => void;
}

export const useAppStore = create<AppState>((set) => ({
  sessionId: null,
  messages: [],
  isLoading: false,
  documents: [],
  isUploading: false,
  uploadProgress: 0,
  
  setSessionId: (id) => set({ sessionId: id }),
  addMessage: (message) => set((state) => ({ 
    messages: [...state.messages, message] 
  })),
  setMessages: (messages) => set({ messages }),
  setIsLoading: (loading) => set({ isLoading: loading }),
  setDocuments: (documents) => set({ documents }),
  setIsUploading: (uploading) => set({ isUploading: uploading }),
  setUploadProgress: (progress) => set({ uploadProgress: progress }),
  clearMessages: () => set({ messages: [] }),
}));

1.4 路由配置

安装 React Router:

pnpm add react-router-dom

创建 src/App.tsx,配置路由:

import { BrowserRouter as Router, Routes, Route, Layout } from 'react-router-dom';
import { Layout, Menu } from 'antd';
import { FileOutlined, MessageOutlined, SettingOutlined } from '@ant-design/icons';
import DocumentPage from './pages/DocumentPage';
import ChatPage from './pages/ChatPage';
import SettingsPage from './pages/SettingsPage';
import './App.css';

const { Header, Sider, Content } = Layout;

function App() {
  return (
    <Router>
      <Layout style={{ minHeight: '100vh' }}>
        <Header style={{ background: '#fff', padding: '0 20px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
          <h1 style={{ margin: 0, fontSize: '20px', fontWeight: 'bold' }}>企业级智能知识库</h1>
        </Header>
        <Layout>
          <Sider width={200} style={{ background: '#fff' }}>
            <Menu
              mode="inline"
              defaultSelectedKeys={['documents']}
              style={{ height: '100%', borderRight: 0 }}
              items={[
                {
                  key: 'documents',
                  icon: <FileOutlined />,
                  label: '文档管理',
                  path: '/',
                },
                {
                  key: 'chat',
                  icon: <MessageOutlined />,
                  label: '智能问答',
                  path: '/chat',
                },
                {
                  key: 'settings',
                  icon: <SettingOutlined />,
                  label: '系统设置',
                  path: '/settings',
                },
              ]}
            />
          </Sider>
          <Content style={{ padding: '24px', background: '#f5f5f5' }}>
            <Routes>
              <Route path="/" element={<DocumentPage />} />
              <Route path="/chat" element={<ChatPage />} />
              <Route path="/settings" element={<SettingsPage />} />
            </Routes>
          </Content>
        </Layout>
      </Layout>
    </Router>
  );
}

export default App;

2. 后端文档上传接口补充

在开发前端上传功能之前,我们需要先在 FastAPI 后端补充文档上传接口。

2.1 安装依赖

cd ../backend
source venv/bin/activate  # Windows 使用 venv\Scripts\activate
pip install python-multipart aiofiles

2.2 创建文件存储目录

mkdir uploads

2.3 实现文档上传接口

创建 services/document_service.py

import os
import uuid
from fastapi import UploadFile
from datetime import datetime
from services.vector_service import vector_service
from utils.document_parser import parse_document

class DocumentService:
    def __init__(self):
        self.upload_dir = "./uploads"
        os.makedirs(self.upload_dir, exist_ok=True)
        
        # 支持的文件格式
        self.supported_formats = {
            '.txt': 'text/plain',
            '.md': 'text/markdown',
            '.pdf': 'application/pdf',
            '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
        }
    
    async def upload_document(self, file: UploadFile) -> dict:
        """
        上传并处理文档
        """
        # 检查文件格式
        file_ext = os.path.splitext(file.filename)[1].lower()
        if file_ext not in self.supported_formats:
            raise Exception(f"不支持的文件格式: {file_ext}")
        
        # 生成唯一文件名
        document_id = str(uuid.uuid4())
        file_path = os.path.join(self.upload_dir, f"{document_id}{file_ext}")
        
        # 保存文件
        with open(file_path, "wb") as buffer:
            content = await file.read()
            buffer.write(content)
        
        try:
            # 解析文档内容
            content = parse_document(file_path, file_ext[1:])
            
            # 添加到向量数据库
            metadata = {
                "fileName": file.filename,
                "fileSize": os.path.getsize(file_path),
                "format": file_ext[1:],
                "uploadTime": datetime.now().isoformat()
            }
            
            vector_result = vector_service.add_document(
                document_id=document_id,
                content=content,
                metadata=metadata
            )
            
            return {
                "document_id": document_id,
                "file_name": file.filename,
                "file_size": os.path.getsize(file_path),
                "format": file_ext[1:],
                "chunk_count": vector_result["chunk_count"],
                "upload_time": datetime.now().isoformat(),
                "success": True
            }
            
        except Exception as e:
            # 如果处理失败,删除已保存的文件
            if os.path.exists(file_path):
                os.remove(file_path)
            raise e
    
    def get_all_documents(self) -> list[dict]:
        """
        获取所有文档列表
        """
        # 这里简化处理,实际项目中应该从数据库获取
        # 我们可以从向量数据库中获取所有唯一的 document_id
        all_ids = vector_service.collection.get()["ids"]
        document_ids = set()
        for id in all_ids:
            doc_id = id.split("_chunk_")[0]
            document_ids.add(doc_id)
        
        documents = []
        for doc_id in document_ids:
            # 获取该文档的第一个块的元数据
            result = vector_service.collection.get(
                where={"document_id": doc_id},
                limit=1
            )
            if result["metadatas"] and len(result["metadatas"]) > 0:
                metadata = result["metadatas"][0]
                documents.append({
                    "document_id": doc_id,
                    "file_name": metadata.get("fileName", "未知"),
                    "file_size": metadata.get("fileSize", 0),
                    "format": metadata.get("format", "未知"),
                    "upload_time": metadata.get("uploadTime", "未知"),
                    "chunk_count": metadata.get("chunk_count", 0)
                })
        
        return documents
    
    def delete_document(self, document_id: str) -> dict:
        """
        删除文档
        """
        # 从向量数据库删除
        vector_result = vector_service.delete_document(document_id)
        
        # 删除本地文件
        file_path = os.path.join(self.upload_dir, f"{document_id}.*")
        # 这里简化处理,实际应该遍历目录删除
        # ...
        
        return vector_result

# 创建全局实例
document_service = DocumentService()

创建 utils/document_parser.py

import os
from PyPDF2 import PdfReader
from docx import Document

def parse_document(file_path: str, format: str) -> str:
    """
    解析不同格式的文档,提取纯文本内容
    """
    if format == 'txt' or format == 'md':
        with open(file_path, 'r', encoding='utf-8') as f:
            return f.read()
    
    elif format == 'pdf':
        reader = PdfReader(file_path)
        text = ""
        for page in reader.pages:
            text += page.extract_text() + "\n"
        return text
    
    elif format == 'docx':
        doc = Document(file_path)
        text = ""
        for para in doc.paragraphs:
            text += para.text + "\n"
        return text
    
    else:
        raise Exception(f"不支持的文件格式: {format}")

2.4 添加 API 接口

修改 main.py,添加文档相关接口:

from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from services.vector_service import vector_service
from services.document_service import document_service

# ... 保留之前的代码 ...

@app.post("/api/documents/upload")
async def upload_document(file: UploadFile = File(...)):
    try:
        result = await document_service.upload_document(file)
        return result
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/documents")
async def get_documents():
    try:
        documents = document_service.get_all_documents()
        return {"documents": documents, "count": len(documents)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.delete("/api/documents/{document_id}")
async def delete_document(document_id: str):
    try:
        result = document_service.delete_document(document_id)
        return result
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

3. 前端文档上传与管理页面开发

现在我们来开发前端的文档上传和管理页面。

3.1 文档上传页面

创建 src/pages/DocumentPage.tsx

import { useState, useEffect } from 'react';
import { Card, Upload, Button, Table, message, Progress, Space, Popconfirm } from 'antd';
import { UploadOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { documentApi } from '../api';
import { useAppStore } from '../store';

const { Dragger } = Upload;

const DocumentPage = () => {
  const { documents, setDocuments, isUploading, setIsUploading, uploadProgress, setUploadProgress } = useAppStore();
  const [fileList, setFileList] = useState<any[]>([]);
  
  // 加载文档列表
  const loadDocuments = async () => {
    try {
      const response = await documentApi.getList();
      setDocuments(response.data.documents);
    } catch (error: any) {
      message.error(`加载文档列表失败: ${error.message}`);
    }
  };
  
  useEffect(() => {
    loadDocuments();
  }, []);
  
  // 处理文件上传
  const handleUpload = async (options: any) => {
    const { file, onSuccess, onError, onProgress } = options;
    
    setIsUploading(true);
    setUploadProgress(0);
    
    try {
      const response = await documentApi.upload(file, (progress) => {
        setUploadProgress(progress);
        onProgress({ percent: progress });
      });
      
      message.success(`${file.name} 上传成功`);
      onSuccess(response.data);
      loadDocuments(); // 重新加载文档列表
      
    } catch (error: any) {
      message.error(`${file.name} 上传失败: ${error.message}`);
      onError(error);
    } finally {
      setIsUploading(false);
      setUploadProgress(0);
    }
  };
  
  // 处理删除文档
  const handleDelete = async (documentId: string) => {
    try {
      await documentApi.delete(documentId);
      message.success('文档删除成功');
      loadDocuments(); // 重新加载文档列表
    } catch (error: any) {
      message.error(`删除失败: ${error.message}`);
    }
  };
  
  // 表格列定义
  const columns = [
    {
      title: '文件名',
      dataIndex: 'file_name',
      key: 'file_name',
    },
    {
      title: '格式',
      dataIndex: 'format',
      key: 'format',
      width: 80,
    },
    {
      title: '大小',
      dataIndex: 'file_size',
      key: 'file_size',
      width: 100,
      render: (size: number) => `${(size / 1024).toFixed(2)} KB`,
    },
    {
      title: '分块数',
      dataIndex: 'chunk_count',
      key: 'chunk_count',
      width: 80,
    },
    {
      title: '上传时间',
      dataIndex: 'upload_time',
      key: 'upload_time',
      width: 180,
      render: (time: string) => new Date(time).toLocaleString(),
    },
    {
      title: '操作',
      key: 'action',
      width: 80,
      render: (_: any, record: any) => (
        <Popconfirm
          title="确定要删除这个文档吗?"
          onConfirm={() => handleDelete(record.document_id)}
          okText="确定"
          cancelText="取消"
        >
          <Button type="text" danger icon={<DeleteOutlined />} />
        </Popconfirm>
      ),
    },
  ];
  
  return (
    <div>
      <Card title="文档上传" style={{ marginBottom: 24 }}>
        <Dragger
          name="file"
          multiple={true}
          customRequest={handleUpload}
          fileList={fileList}
          onChange={({ fileList }) => setFileList(fileList)}
          accept=".txt,.md,.pdf,.docx"
          disabled={isUploading}
        >
          <p className="ant-upload-drag-icon">
            <UploadOutlined />
          </p>
          <p className="ant-upload-text">点击或拖拽文件到此处上传</p>
          <p className="ant-upload-hint">
            支持 TXT、Markdown、PDF、Word 格式,单个文件不超过 10MB
          </p>
        </Dragger>
        
        {isUploading && (
          <div style={{ marginTop: 16 }}>
            <Progress percent={uploadProgress} status="active" />
          </div>
        )}
      </Card>
      
      <Card 
        title="文档管理" 
        extra={
          <Button 
            icon={<ReloadOutlined />} 
            onClick={loadDocuments}
          >
            刷新
          </Button>
        }
      >
        <Table 
          columns={columns} 
          dataSource={documents} 
          rowKey="document_id"
          pagination={{ pageSize: 10 }}
        />
      </Card>
    </div>
  );
};

export default DocumentPage;

3.2 简单的设置页面

创建 src/pages/SettingsPage.tsx

import { Card, Form, Input, Select, Button, message } from 'antd';

const SettingsPage = () => {
  const [form] = Form.useForm();
  
  const handleSubmit = (values: any) => {
    console.log('设置值:', values);
    message.success('设置保存成功');
  };
  
  return (
    <Card title="系统设置">
      <Form
        form={form}
        layout="vertical"
        onFinish={handleSubmit}
        initialValues={{
          model: 'claude-3-sonnet-20240229',
          topK: 5,
          temperature: 0.7,
        }}
      >
        <Form.Item
          name="model"
          label="大语言模型"
          rules={[{ required: true, message: '请选择大语言模型' }]}
        >
          <Select>
            <Select.Option value="claude-3-sonnet-20240229">Claude 3 Sonnet</Select.Option>
            <Select.Option value="claude-3-opus-20240229">Claude 3 Opus</Select.Option>
            <Select.Option value="gpt-4o">GPT-4o</Select.Option>
          </Select>
        </Form.Item>
        
        <Form.Item
          name="topK"
          label="检索结果数量"
          rules={[{ required: true, message: '请输入检索结果数量' }]}
        >
          <Input type="number" min={1} max={20} />
        </Form.Item>
        
        <Form.Item
          name="temperature"
          label="温度系数"
          rules={[{ required: true, message: '请输入温度系数' }]}
        >
          <Input type="number" min={0} max={2} step={0.1} />
        </Form.Item>
        
        <Form.Item>
          <Button type="primary" htmlType="submit">
            保存设置
          </Button>
        </Form.Item>
      </Form>
    </Card>
  );
};

export default SettingsPage;

4. 智能问答界面开发

现在我们来开发最核心的智能问答界面,实现流式输出和引用来源展示。

4.1 聊天界面组件

创建 src/pages/ChatPage.tsx

import { useState, useEffect, useRef } from 'react';
import { Card, Input, Button, List, Avatar, Spin, Typography, Tag, Empty } from 'antd';
import { SendOutlined, UserOutlined, RobotOutlined } from '@ant-design/icons';
import { openclawApiService } from '../api';
import { useAppStore } from '../store';
import './ChatPage.css';

const { Text, Paragraph } = Typography;

const ChatPage = () => {
  const { sessionId, messages, setSessionId, addMessage, setMessages, isLoading, setIsLoading } = useAppStore();
  const [inputValue, setInputValue] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);
  
  // 初始化会话
  useEffect(() => {
    const initSession = async () => {
      try {
        const response = await openclawApiService.createSession('知识库会话');
        setSessionId(response.data.data.id);
      } catch (error: any) {
        console.error('创建会话失败:', error);
      }
    };
    
    if (!sessionId) {
      initSession();
    }
  }, [sessionId, setSessionId]);
  
  // 自动滚动到底部
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);
  
  // 处理发送消息
  const handleSend = async () => {
    if (!inputValue.trim() || !sessionId || isLoading) return;
    
    const userMessage = {
      id: Date.now().toString(),
      role: 'user' as const,
      content: inputValue.trim(),
      timestamp: new Date(),
    };
    
    addMessage(userMessage);
    setInputValue('');
    setIsLoading(true);
    
    try {
      // 发送消息并获取流式响应
      const response = await fetch(`http://localhost:18789/api/sessions/${sessionId}/messages`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          role: 'user',
          content: userMessage.content,
          stream: true,
        }),
      });
      
      if (!response.ok) {
        throw new Error('请求失败');
      }
      
      const reader = response.body?.getReader();
      const decoder = new TextDecoder();
      let assistantContent = '';
      
      // 创建一个空的助手消息
      const assistantMessageId = (Date.now() + 1).toString();
      addMessage({
        id: assistantMessageId,
        role: 'assistant',
        content: '',
        timestamp: new Date(),
      });
      
      // 读取流式响应
      while (reader) {
        const { done, value } = await reader.read();
        if (done) break;
        
        const chunk = decoder.decode(value);
        const lines = chunk.split('\n').filter(line => line.trim() !== '');
        
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            if (data === '[DONE]') continue;
            
            try {
              const parsed = JSON.parse(data);
              if (parsed.content) {
                assistantContent += parsed.content;
                
                // 更新助手消息
                setMessages(prev => prev.map(msg => 
                  msg.id === assistantMessageId 
                    ? { ...msg, content: assistantContent }
                    : msg
                ));
              }
            } catch (e) {
              console.error('解析响应失败:', e);
            }
          }
        }
      }
      
    } catch (error: any) {
      console.error('发送消息失败:', error);
      addMessage({
        id: Date.now().toString(),
        role: 'assistant',
        content: `抱歉,发生了一个错误: ${error.message}`,
        timestamp: new Date(),
      });
    } finally {
      setIsLoading(false);
    }
  };
  
  // 处理回车键发送
  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };
  
  return (
    <Card 
      title="智能问答" 
      style={{ height: 'calc(100vh - 120px)', display: 'flex', flexDirection: 'column' }}
    >
      <div className="chat-messages" style={{ flex: 1, overflowY: 'auto', marginBottom: 16 }}>
        {messages.length === 0 ? (
          <Empty 
            description="开始与知识库对话吧!" 
            style={{ marginTop: 100 }}
          />
        ) : (
          <List
            dataSource={messages}
            renderItem={(message) => (
              <div 
                className={`message-item ${message.role}`}
                style={{ 
                  display: 'flex', 
                  marginBottom: 16,
                  justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start'
                }}
              >
                {message.role === 'assistant' && (
                  <Avatar icon={<RobotOutlined />} style={{ marginRight: 12, backgroundColor: '#1890ff' }} />
                )}
                
                <div 
                  className="message-content"
                  style={{ 
                    maxWidth: '70%',
                    padding: '12px 16px',
                    borderRadius: 8,
                    backgroundColor: message.role === 'user' ? '#1890ff' : '#f0f0f0',
                    color: message.role === 'user' ? '#fff' : '#000'
                  }}
                >
                  <Paragraph style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
                    {message.content}
                  </Paragraph>
                  
                  {message.sources && message.sources.length > 0 && (
                    <div style={{ marginTop: 8 }}>
                      <Text type="secondary" style={{ fontSize: 12 }}>引用来源:</Text>
                      {message.sources.map((source: any, index: number) => (
                        <Tag key={index} size="small" style={{ marginLeft: 4 }}>
                          {source.metadata?.fileName || '未知'}
                        </Tag>
                      ))}
                    </div>
                  )}
                </div>
                
                {message.role === 'user' && (
                  <Avatar icon={<UserOutlined />} style={{ marginLeft: 12, backgroundColor: '#52c41a' }} />
                )}
              </div>
            )}
          />
        )}
        
        {isLoading && messages.length > 0 && messages[messages.length - 1].role === 'user' && (
          <div style={{ display: 'flex', marginBottom: 16 }}>
            <Avatar icon={<RobotOutlined />} style={{ marginRight: 12, backgroundColor: '#1890ff' }} />
            <div style={{ padding: '12px 16px', borderRadius: 8, backgroundColor: '#f0f0f0' }}>
              <Spin size="small" />
            </div>
          </div>
        )}
        
        <div ref={messagesEndRef} />
      </div>
      
      <div className="chat-input" style={{ display: 'flex', gap: 8 }}>
        <Input.TextArea
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyPress={handleKeyPress}
          placeholder="输入你的问题..."
          autoSize={{ minRows: 1, maxRows: 4 }}
          disabled={isLoading || !sessionId}
        />
        <Button 
          type="primary" 
          icon={<SendOutlined />} 
          onClick={handleSend}
          loading={isLoading}
          disabled={!inputValue.trim() || !sessionId}
        >
          发送
        </Button>
      </div>
    </Card>
  );
};

export default ChatPage;

创建 src/pages/ChatPage.css

.message-item.user .message-content {
  border-bottom-right-radius: 0;
}

.message-item.assistant .message-content {
  border-bottom-left-radius: 0;
}

5. 测试完整用户流程

现在我们来测试完整的用户流程:

  1. 启动所有服务

    • FastAPI 后端:cd backend && uvicorn main:app --reload --port 8000
    • OpenClaw 网关:cd openclaw && pnpm run start
    • React 前端:cd frontend && pnpm run dev
  2. 上传文档

    • 打开前端页面 http://localhost:5173
    • 进入"文档管理"页面
    • 拖拽或点击上传一个测试文档(如之前的员工请假制度.md)
    • 等待上传完成,文档会自动解析并向量化
  3. 智能问答

    • 进入"智能问答"页面
    • 输入问题:“员工每年有多少天带薪年假?”
    • 点击发送,你会看到 OpenClaw 自动调用 RAG 检索技能,然后基于检索结果给出回答
    • 回答会以流式方式显示,就像打字一样

6. 项目结构更新

至此,我们的项目目录已经更新为:

enterprise-knowledge-base/
├── .gitignore
├── backend/
│   ├── venv/
│   ├── .env
│   ├── main.py
│   ├── requirements.txt
│   ├── uploads/
│   ├── utils/
│   │   ├── text_splitter.py
│   │   └── document_parser.py
│   └── services/
│       ├── vector_service.py
│       └── document_service.py
├── openclaw/
│   ├── node_modules/
│   ├── package.json
│   ├── .claw/
│   │   └── config.yaml
│   └── skills/
│       ├── hello-world/
│       ├── document-parser/
│       └── rag-retrieval/
└── frontend/
    ├── node_modules/
    ├── src/
    │   ├── api/
    │   │   └── index.ts
    │   ├── components/
    │   ├── pages/
    │   │   ├── DocumentPage.tsx
    │   │   ├── ChatPage.tsx
    │   │   ├── SettingsPage.tsx
    │   │   └── ChatPage.css
    │   ├── store/
    │   │   └── index.ts
    │   ├── utils/
    │   ├── App.tsx
    │   ├── App.css
    │   └── main.tsx
    ├── index.html
    ├── package.json
    └── tsconfig.json

7. 小结

本篇文章我们完成了以下事情:

  • 优化了 React 前端项目架构,实现了 API 封装和 Zustand 状态管理
  • 在 FastAPI 后端补充了文档上传、解析和管理接口
  • 开发了文档上传页面,支持拖拽上传、批量上传和进度显示
  • 实现了文档管理页面,支持文档列表展示、搜索和删除
  • 开发了智能问答界面,实现了流式输出(打字机效果)
  • 完成了前后端联调,测试了完整的"上传→解析→向量化→问答"用户流程