AI全栈开发实战:前端流式对话与Markdown渲染指南

2026-06-18阅读 0热度 0
markdown

上一篇文章带大家搭建了前端页面的基础结构,今天正式进入最核心的部分——对话页面的实现。这不是简单的静态页面,需要支持流式渲染、打字机特效、对话管理等关键能力,是整个产品的灵魂所在。

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

1. 对话页面设计

先看整体页面布局设计图,后续代码看起来更有画面感: ``` ┌─────────────────────────────────────────────┐ │ ← 返回知识库 标题 对话历史 清空对话 │ ├─────────────────────────────────────────────┤ │ │ │ 用户消息 │ │ ┌──────────────────────────────────────┐ │ │ │ 这就是用户发送的消息内容 │ │ │ └──────────────────────────────────────┘ │ │ │ │ AI 回复(流式渲染) │ │ ┌──────────────────────────────────────┐ │ │ │ 这是 AI 的回复,支持 Markdown │ │ │ │ ```python │ │ │ │ def hello(): │ │ │ │ print("world") │ │ │ │ ``` │ │ │ │ │ │ │ │ 引用来源 [1] │ │ │ └──────────────────────────────────────┘ │ │ │ │ [引用 1: 文档名称.pdf] │ │ │ ├─────────────────────────────────────────────┤ │ [输入框...] [发送] │ └─────────────────────────────────────────────┘ ``` 整体结构清晰:头部包含导航和信息,消息区域负责展示对话,底部固定输入框。AI 回复的 Markdown 渲染和引用来源展示是核心亮点。

2. SSE 流式对话 Hook

对话页面的底层核心是一个自定义 Hook——`useChat`。它负责与后端的所有通信逻辑,包括发送消息、接收流式数据、中断请求、加载历史等。直接上代码,重点部分逐一拆解: ```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([]); const [isLoading, setIsLoading] = useState(false); const [convId, setConvId] = useState(); const abortRef = useRef(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, }; } ``` **几个关键点值得留意:** - **SSE 流式接收**:通过 `fetch` 获取 `ReadableStream`,逐块读取并解析 `data:` 前缀的 SSE 事件,实现打字机效果。 - **AbortController 控制**:用 `abortRef` 保存当前 `AbortController` 实例,当用户点击"停止"时,通过 `abort()` 立即中断 fetch 请求。 - **占位符机制**:发送消息后立刻插入一条空的 AI 回复消息,后续流式数据直接更新其内容,保证 UI 上的连续性。

3. 对话页面组件

有了 `useChat` Hook,接下来将其组装成完整的 `Chat` 页面组件。页面包含消息列表、预设问题、输入框和停止按钮等完整交互: ```typescript // frontend/src/pages/Chat.tsx import { useState, useRef, useEffect } from "react"; import { useSearchParams, useNa vigate } 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 na vigate = useNa vigate(); const kbId = searchParams.get("kb") || ""; const { messages, isLoading, sendMessage, stopGeneration, clearMessages, } = useChat(kbId); const [input, setInput] = useState(""); const bottomRef = useRef(null); const inputRef = useRef(null); // 自动滚动到底部 useEffect(() => { bottomRef.current?.scrollIntoView({ beha vior: "smooth" }); }, [messages]); const handleSend = () => { if (!input.trim() || isLoading || !kbId) return; sendMessage(input.trim()); setInput(""); }; // 预设问题 const quickQuestions = [ "这个项目的主要功能是什么?", "文档中对系统架构的描述是怎样的?", "有哪些关键的技术决策?", ]; if (!kbId) { return (
?

选择一个知识库开始问答

从知识库详情页点击"开始问答"

); } return (
{/* 头部 */}
知识库问答
{/* 消息列表 */}
{messages.length === 0 ? (

你可以问关于知识库中文档的任何问题

{quickQuestions.map((q) => ( ))}
) : ( messages.map((msg) => (
{msg.role === "assistant" ? (
{msg.content || "..."}
) : (

{msg.content}

)} {/* 引用来源 */} {msg.citations && msg.citations.length > 0 && (

来源:

{msg.citations.map((c, i) => ( [{i + 1}] {c.source} ))}
)}
)) )}
{/* 输入区 */}
setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend() } placeholder={kbId ? "输入问题..." : "请先选择知识库"} disabled={!kbId || isLoading} className="flex-1" /> {isLoading ? ( ) : ( )}
); } ``` 页面设计有几个值得关注的细节: - **预设问题**:消息为空时展示一组预设问题,降低用户使用门槛。点击后直接触发 `sendMessage`。 - **自动滚动**:通过 `bottomRef` 实现新消息出现时自动滚动到底部,交互体验很自然。 - **输入区状态控制**:根据 `isLoading` 动态切换"发送"和"停止"按钮,避免用户在流式生成过程中发送重复消息。

4. Markdown 渲染优化

对话中 AI 回复的代码块需要更好的视觉效果。在全局 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; } ``` 几点说明: - 代码块采用深色背景(`#1e293b`),与浅色正文形成对比,视觉上更突出。 - 行内代码用浅灰色底色高亮,方便在段落中快速识别。 - 表格样式统一,表头加底色,边框清晰,符合文档阅读习惯。

5. 对话列表页面

为了管理用户的对话记录,还需要一个 `ConversationList` 组件。它支持显示、选择和删除对话: ```typescript // 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([]); 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 (
对话历史
{loading ? (
加载中...
) : convs.length === 0 ? (
暂无对话记录
) : ( convs.map((conv) => (
onSelect(conv.id)} > ? {conv.title}
)) )}
); } ``` 这个组件逻辑很清晰:加载列表、显示状态、支持选择和高亮当前项,删除按钮有 hover 效果,交互反馈到位。

6. 对话侧边栏集成

现在把 `ConversationList` 集成到主对话页面中,形成带历史侧边栏的完整布局。在 `Chat.tsx` 的 `return` 中,将原本的单一列布局改为两列结构: ```typescript // 在 Chat.tsx 的 return 中增加侧边栏
{/* 对话历史侧边栏 */}
loadHistory(id)} />
{/* 对话主区域 */}
{/* 之前的对话内容 */}
``` 侧边栏在桌面端(`md:` 断点)显示,移动端自动隐藏。选择历史对话后,通过 `loadHistory` 加载对应会话的消息列表。

7. 流式停止功能

流式生成过程中,用户随时可以点击"停止"按钮中断请求。实现已经在 `useChat` hook 中完成,核心机制就是 `AbortController`: - 发送请求时创建新的 `AbortController`,并将其 `signal` 传递到 fetch 请求中。 - 停止时调用 `abortRef.current.abort()`,fetch 会抛出 `AbortError`,我们捕获后不再更新消息内容。 - UI 上通过 `isLoading` 切换按钮状态。

8. 验证

功能开发完成后,可以按照以下步骤进行验证,检查是否所有交互都符合预期: ``` # 1. 进入知识库详情页 → 点击"开始问答" # 2. 看到预设问题列表 # 3. 点击预设问题 → 看到流式输出(打字机效果) # 4. 输入新的问题 → 发送 # 5. 点击"停止" → 中断生成 # 6. 刷新页面 → 对话历史保留 # 7. 点击历史对话 → 加载历史消息 ```

总结

今天完成了对话界面的核心开发,梳理一下主要组件及其能力:
组件功能
useChat HookSSE 流式接收、AbortController 停止、历史加载
Chat 页面流式渲染 + Markdown 展示 + 引用来源
ConversationList对话历史侧边栏
代码块样式暗色主题 + 表格样式 + 行内代码
至此,KNow 产品的前端核心界面基本完成:登录 → 仪表盘 → 知识库管理 → 对话问答。下一篇我们将进一步完善前端,添加用户设置、API Key 管理等辅助功能。
免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策