AI 全栈开发实战(8):前端开发(二)——流式对话界面与 Markdown 渲染

前言

上一篇搭建了前端页面框架。今天实现最核心的用户界面------对话页面,包括流式渲染、打字机效果、对话管理等功能。

1. 对话页面设计

less 复制代码
┌─────────────────────────────────────────────┐
│  ← 返回知识库   标题   对话历史 清空对话    │
├─────────────────────────────────────────────┤
│                                             │
│  用户消息                                    │
│  ┌──────────────────────────────────────┐   │
│  │ 这就是用户发送的消息内容               │   │
│  └──────────────────────────────────────┘   │
│                                             │
│  AI 回复(流式渲染)                         │
│  ┌──────────────────────────────────────┐   │
│  │ 这是 AI 的回复,支持 Markdown         │   │
│  │ ```python                            │   │
│  │ def hello():                         │   │
│  │     print("world")                   │   │
│  │ ```                                │   │
│  │                                     │   │
│  │ 引用来源 [1]                         │   │
│  └──────────────────────────────────────┘   │
│                                             │
│  [引用 1: 文档名称.pdf]                      │
│                                             │
├─────────────────────────────────────────────┤
│  [输入框...                      ] [发送]   │
└─────────────────────────────────────────────┘

2. SSE 流式对话 Hook

typescript 复制代码
// frontend/src/hooks/useChat.ts
import { useState, useRef, useCallback } from "react";
import api from "@/lib/api";

export interface ChatMessage {
  id: string;
  role: "user" | "assistant";
  content: string;
  citations?: Array<{
    source: string;
    text: string;
    score: number;
  }>;
}

export function useChat(kbId: string) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [convId, setConvId] = useState<string | undefined>();
  const abortRef = useRef<AbortController | null>(null);

  const sendMessage = useCallback(
    async (content: string) => {
      // 添加用户消息
      const userMsg: ChatMessage = {
        id: Date.now().toString(),
        role: "user",
        content,
      };
      setMessages((prev) => [...prev, userMsg]);
      setIsLoading(true);

      // 占位符------空助手消息
      const assistantId = (Date.now() + 1).toString();
      setMessages((prev) => [
        ...prev,
        { id: assistantId, role: "assistant", content: "" },
      ]);

      try {
        abortRef.current = new AbortController();
        const token = localStorage.getItem("token");

        const response = await fetch("/api/chat/stream", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`,
          },
          body: JSON.stringify({
            kb_id: kbId,
            message: content,
            conversation_id: convId,
          }),
          signal: abortRef.current.signal,
        });

        const reader = response.body!.getReader();
        const decoder = new TextDecoder();
        let buffer = "";
        let fullContent = "";

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          buffer += decoder.decode(value, { stream: true });
          const lines = buffer.split("\n");
          buffer = lines.pop() || "";

          for (const line of lines) {
            if (line.startsWith("data: ")) {
              const data = line.slice(6);
              if (data === "[DONE]") continue;
              fullContent += data;
              // 更新助手消息内容(增量追加)
              setMessages((prev) =>
                prev.map((m) =>
                  m.id === assistantId ? { ...m, content: fullContent } : m
                )
              );
            }
          }
        }
      } catch (err: any) {
        if (err.name !== "AbortError") {
          setMessages((prev) =>
            prev.map((m) =>
              m.id === assistantId
                ? { ...m, content: "请求失败,请重试" }
                : m
            )
          );
        }
      } finally {
        setIsLoading(false);
      }
    },
    [kbId, convId]
  );

  const stopGeneration = useCallback(() => {
    abortRef.current?.abort();
    setIsLoading(false);
  }, []);

  const loadHistory = useCallback(async (conversationId: string) => {
    setConvId(conversationId);
    try {
      const { data } = await api.get(
        `/conversations/${conversationId}/messages`
      );
      setMessages(
        data.map((m: any) => ({
          id: m.id,
          role: m.role,
          content: m.content,
          citations: m.citations || [],
        }))
      );
    } catch (e) {
      console.error("Failed to load history", e);
    }
  }, []);

  const clearMessages = useCallback(() => {
    setMessages([]);
    setConvId(undefined);
  }, []);

  return {
    messages,
    isLoading,
    sendMessage,
    stopGeneration,
    loadHistory,
    clearMessages,
    setConvId,
  };
}

3. 对话页面组件

tsx 复制代码
// frontend/src/pages/Chat.tsx
import { useState, useRef, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useChat } from "@/hooks/useChat";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

export default function Chat() {
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const kbId = searchParams.get("kb") || "";
  const {
    messages,
    isLoading,
    sendMessage,
    stopGeneration,
    clearMessages,
  } = useChat(kbId);
  const [input, setInput] = useState("");
  const bottomRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  // 自动滚动到底部
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  const handleSend = () => {
    if (!input.trim() || isLoading || !kbId) return;
    sendMessage(input.trim());
    setInput("");
  };

  // 预设问题
  const quickQuestions = [
    "这个项目的主要功能是什么?",
    "文档中对系统架构的描述是怎样的?",
    "有哪些关键的技术决策?",
  ];

  if (!kbId) {
    return (
      <div className="flex-1 flex items-center justify-center min-h-[60vh]">
        <div className="text-center">
          <div className="text-6xl mb-4">💬</div>
          <h2 className="text-xl font-semibold text-gray-600">
            选择一个知识库开始问答
          </h2>
          <p className="text-gray-400 mt-2">
            从知识库详情页点击"开始问答"
          </p>
          <Button className="mt-4" onClick={() => navigate("/dashboard")}>
            前往知识库
          </Button>
        </div>
      </div>
    );
  }

  return (
    <div className="flex flex-col h-[calc(100vh-8rem)] max-w-4xl mx-auto">
      {/* 头部 */}
      <div className="flex items-center justify-between px-4 py-3 border-b bg-white rounded-t-xl">
        <button
          onClick={() => navigate(-1)}
          className="text-sm text-gray-400 hover:text-gray-600"
        >
          ← 返回
        </button>
        <span className="text-sm text-gray-500">
          知识库问答
        </span>
        <Button
          variant="ghost"
          size="sm"
          onClick={clearMessages}
          disabled={messages.length === 0}
        >
          清空对话
        </Button>
      </div>

      {/* 消息列表 */}
      <div className="flex-1 overflow-y-auto px-4 py-4 space-y-4 bg-white">
        {messages.length === 0 ? (
          <div className="flex items-center justify-center h-full">
            <div className="text-center max-w-md">
              <p className="text-gray-500 text-sm">
                你可以问关于知识库中文档的任何问题
              </p>
              <div className="mt-4 space-y-2">
                {quickQuestions.map((q) => (
                  <button
                    key={q}
                    className="block w-full text-left px-4 py-2.5 rounded-xl border border-gray-200 hover:bg-gray-50 text-sm text-gray-700 transition"
                    onClick={() => sendMessage(q)}
                  >
                    {q}
                  </button>
                ))}
              </div>
            </div>
          </div>
        ) : (
          messages.map((msg) => (
            <div
              key={msg.id}
              className={`flex ${
                msg.role === "user" ? "justify-end" : "justify-start"
              }`}
            >
              <div
                className={`max-w-[80%] rounded-xl px-4 py-3 ${
                  msg.role === "user"
                    ? "bg-blue-600 text-white"
                    : "bg-gray-50 border border-gray-100 text-gray-800"
                }`}
              >
                {msg.role === "assistant" ? (
                  <div className="prose prose-sm max-w-none">
                    <ReactMarkdown remarkPlugins={[remarkGfm]}>
                      {msg.content || "..."}
                    </ReactMarkdown>
                  </div>
                ) : (
                  <p className="text-sm whitespace-pre-wrap">{msg.content}</p>
                )}

                {/* 引用来源 */}
                {msg.citations && msg.citations.length > 0 && (
                  <div className="mt-3 pt-3 border-t border-gray-200">
                    <p className="text-xs text-gray-400 mb-1">来源:</p>
                    {msg.citations.map((c, i) => (
                      <span
                        key={i}
                        className="inline-block text-xs bg-gray-100 rounded px-1.5 py-0.5 mr-1 mb-1"
                      >
                        [{i + 1}] {c.source}
                      </span>
                    ))}
                  </div>
                )}
              </div>
            </div>
          ))
        )}
        <div ref={bottomRef} />
      </div>

      {/* 输入区 */}
      <div className="border-t bg-white p-4 rounded-b-xl">
        <div className="flex gap-2">
          <Input
            ref={inputRef}
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) =>
              e.key === "Enter" && !e.shiftKey && handleSend()
            }
            placeholder={kbId ? "输入问题..." : "请先选择知识库"}
            disabled={!kbId || isLoading}
            className="flex-1"
          />
          {isLoading ? (
            <Button variant="outline" onClick={stopGeneration}>
              停止
            </Button>
          ) : (
            <Button onClick={handleSend} disabled={!kbId || !input.trim()}>
              发送
            </Button>
          )}
        </div>
      </div>
    </div>
  );
}

4. Markdown 渲染优化

对话中的代码块需要更好的样式。在全局 CSS 中添加代码块样式:

css 复制代码
/* frontend/src/index.css(追加) */
.prose pre {
  background: #1e293b;
  color: #e2e8f0;
  border-radius: 8px;
  padding: 16px;
  overflow-x: auto;
  font-size: 13px;
  line-height: 1.6;
}

.prose code {
  font-size: 0.875em;
  font-weight: 500;
  font-family: "JetBrains Mono", "Fira Code", monospace;
}

.prose p code {
  background: #f1f5f9;
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 0.85em;
}

.prose table {
  border-collapse: collapse;
  width: 100%;
}

.prose th,
.prose td {
  border: 1px solid #e2e8f0;
  padding: 8px 12px;
  text-align: left;
  font-size: 14px;
}

.prose th {
  background: #f8fafc;
  font-weight: 600;
}

5. 对话列表页面

tsx 复制代码
// frontend/src/components/ConversationList.tsx
import { useState, useEffect } from "react";
import { listConversations, Conversation, deleteConversation } from "@/api/chat";
import { Button } from "@/components/ui/button";

interface Props {
  kbId?: string;
  onSelect: (convId: string) => void;
  selectedConv?: string;
}

export function ConversationList({ kbId, onSelect, selectedConv }: Props) {
  const [convs, setConvs] = useState<Conversation[]>([]);
  const [loading, setLoading] = useState(true);

  const load = async () => {
    setLoading(true);
    try {
      const data = await listConversations(kbId);
      setConvs(data);
    } catch (e) {
      console.error(e);
    }
    setLoading(false);
  };

  useEffect(() => {
    load();
  }, [kbId]);

  const handleDelete = async (id: string) => {
    await deleteConversation(id);
    load();
  };

  return (
    <div className="space-y-1">
      <div className="flex items-center justify-between px-3 py-2">
        <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
          对话历史
        </span>
        <Button variant="ghost" size="sm" className="text-xs" onClick={load}>
          刷新
        </Button>
      </div>

      {loading ? (
        <div className="text-center py-8 text-xs text-gray-400">
          加载中...
        </div>
      ) : convs.length === 0 ? (
        <div className="text-center py-8 text-xs text-gray-400">
          暂无对话记录
        </div>
      ) : (
        convs.map((conv) => (
          <div
            key={conv.id}
            className={`group flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer text-sm transition ${
              selectedConv === conv.id
                ? "bg-blue-50 text-blue-600"
                : "hover:bg-gray-100 text-gray-700"
            }`}
            onClick={() => onSelect(conv.id)}
          >
            <span className="truncate flex-1">
              💬 {conv.title}
            </span>
            <button
              className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition ml-2"
              onClick={(e) => {
                e.stopPropagation();
                handleDelete(conv.id);
              }}
            >
              ✕
            </button>
          </div>
        ))
      )}
    </div>
  );
}

6. 对话侧边栏集成

在主对话页面中集成对话历史侧边栏:

tsx 复制代码
// 在 Chat.tsx 的 return 中增加侧边栏
<div className="flex h-[calc(100vh-8rem)] max-w-6xl mx-auto">
  {/* 对话历史侧边栏 */}
  <div className="w-64 hidden md:block border-r bg-white rounded-l-xl p-2 overflow-y-auto">
    <ConversationList
      kbId={kbId}
      selectedConv={convId}
      onSelect={(id) => loadHistory(id)}
    />
  </div>

  {/* 对话主区域 */}
  <div className="flex-1 flex flex-col">
    {/* 之前的对话内容 */}
  </div>
</div>

7. 流式停止功能

当用户点击"停止"按钮时,用 AbortController 中断 fetch 请求:

typescript 复制代码
// 已经在 useChat hook 中实现了
// abortRef.current = new AbortController();
// signal: abortRef.current.signal,
// 点击停止时调用 stopGeneration()

8. 验证

bash 复制代码
# 1. 进入知识库详情页 → 点击"开始问答"
# 2. 看到预设问题列表
# 3. 点击预设问题 → 看到流式输出(打字机效果)
# 4. 输入新的问题 → 发送
# 5. 点击"停止" → 中断生成
# 6. 刷新页面 → 对话历史保留
# 7. 点击历史对话 → 加载历史消息

总结

今天完成了对话界面:

组件 功能
useChat Hook SSE 流式接收、AbortController 停止、历史加载
Chat 页面 流式渲染 + Markdown 展示 + 引用来源
ConversationList 对话历史侧边栏
代码块样式 暗色主题 + 表格样式 + 行内代码

至此,KNow 产品的前端核心界面基本完成:登录 → 仪表盘 → 知识库管理 → 对话问答。

下一篇我们将进一步完善前端,添加用户设置、API Key 管理等辅助功能。


本文是 《AI 全栈开发实战------做一个真正的产品》 系列的第 8 篇。 系列目录: 1-6. ✅ 后端 7. ✅ 前端(一)页面框架 8. ✅ 前端(二)对话界面 ← 你在这里 9. 📝 前端(三)用户设置与 API Key

本文由 Zyentor(智元界) 原创发布


本文发布于 Zyentor(智元界) ------ AI 开发者社区 原文链接:www.zyentor.com/news/3813

相关推荐
MatrixOrigin1 小时前
MatrixOne Git4Data 技术详解(三):MatrixOne 架构及 Git4Data 原理解析,快照、Diff、Merge 凭什么这么快
数据库·人工智能·数据平台·矩阵起源·数据底座
KaMeidebaby1 小时前
卡梅德生物技术快报 | Fab 合成文库构建与抗体筛选实验流程及数据解析
人工智能·python·tcp/ip·算法·机器学习
培培说证1 小时前
大数据、人工智能、计算机、软件工程,到底怎么选?
大数据·人工智能·软件工程
装不满的克莱因瓶1 小时前
掌握3D CNN模型结构——从时空特征建模到视频理解与医学影像核心架构
人工智能·pytorch·python·深度学习·神经网络·3d·cnn
金融小师妹1 小时前
基于AI事件驱动模型与验证溢价框架的市场分析:从预期交易到事实验证,原油与黄金面临关键定价重构
大数据·人工智能·算法·均值算法·线性回归
YOLO数据集集合1 小时前
无人机航拍RGBT双模态行人检测数据集 | 可见光红外对齐 低空小目标检测 多模态计算机视觉基准数据
人工智能·深度学习·目标检测·计算机视觉·无人机
古希腊掌管代码的神THU1 小时前
解析 MiniMax M3 多模态大模型的架构/源码?
人工智能·深度学习·自然语言处理·面试
卡卡罗特AI1 小时前
Codex复刻小米MiMoCode官网,丝滑融入项目,只需要3步!保姆级教程!
人工智能·ai编程
sunneo1 小时前
本周 AI 新动态精选(2026.06.08–06.14)
人工智能·aigc·ai编程·ai写作·ai-native