尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

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

AI 全栈开发实战(8):前端开发(二)——流式对话界面与 Markdown 渲染
📅 发布时间:2026/7/5 15:10:09

前言

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

1. 对话页面设计

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

2. SSE 流式对话 Hook

// frontend/src/hooks/useChat.tsimport{useState,useRef,useCallback}from"react";importapifrom"@/lib/api";exportinterfaceChatMessage{id:string;role:"user"|"assistant";content:string;citations?:Array<{source:string;text:string;score:number;}>;}exportfunctionuseChat(kbId:string){const[messages,setMessages]=useState<ChatMessage[]>([]);const[isLoading,setIsLoading]=useState(false);const[convId,setConvId]=useState<string|undefined>();constabortRef=useRef<AbortController|null>(null);constsendMessage=useCallback(async(content:string)=>{// 添加用户消息constuserMsg:ChatMessage={id:Date.now().toString(),role:"user",content,};setMessages((prev)=>[...prev,userMsg]);setIsLoading(true);// 占位符——空助手消息constassistantId=(Date.now()+1).toString();setMessages((prev)=>[...prev,{id:assistantId,role:"assistant",content:""},]);try{abortRef.current=newAbortController();consttoken=localStorage.getItem("token");constresponse=awaitfetch("/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,});constreader=response.body!.getReader();constdecoder=newTextDecoder();letbuffer="";letfullContent="";while(true){const{done,value}=awaitreader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});constlines=buffer.split("\n");buffer=lines.pop()||"";for(constlineoflines){if(line.startsWith("data: ")){constdata=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]);conststopGeneration=useCallback(()=>{abortRef.current?.abort();setIsLoading(false);},[]);constloadHistory=useCallback(async(conversationId:string)=>{setConvId(conversationId);try{const{data}=awaitapi.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);}},[]);constclearMessages=useCallback(()=>{setMessages([]);setConvId(undefined);},[]);return{messages,isLoading,sendMessage,stopGeneration,loadHistory,clearMessages,setConvId,};}

3. 对话页面组件

// 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 中添加代码块样式:

/* 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. 对话列表页面

// 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. 对话侧边栏集成

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

// 在 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 请求:

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

8. 验证

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

总结

今天完成了对话界面:

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

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

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


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


如果觉得有用,欢迎点赞 + 收藏 + 关注。这个系列从产品定义写到生产上线,全部代码开源可运行,带你从零交付一个真正的 AI 产品。

相关新闻

  • FastbootEnhance:Windows平台Android设备分区管理与Payload解析的专业解决方案
  • C语言 用递归实现revserse_string详解(附有画图)
  • web-第7次课后作业-2

最新新闻

  • 终极跨版本Python字节码逆向方案:pycdc完整指南
  • 如何高效部署Qwen3-Coder-30B-A3B-Instruct:技术决策者的终极指南
  • WeChatMsg技术解析:从聊天记录提取到AI数据资产化的完整实现方案
  • 三步搞定国家中小学智慧教育平台电子课本下载:开源工具完全指南
  • Minecraft城市生成终极指南:用Arnis轻松打造真实世界景观
  • KeyDecoder:手机秒变专业钥匙解码器,Flutter+OpenCV实现高精度机械钥匙测量

日新闻

  • 基于YOLOv12的番茄成熟度智能检测系统开发
  • 终极RimWorld模组管理指南:用RimSort告别模组冲突烦恼
  • AI Agent框架开发:从理论到实践的完整指南

周新闻

  • 基于YOLOv12的番茄成熟度智能检测系统开发
  • 终极RimWorld模组管理指南:用RimSort告别模组冲突烦恼
  • AI Agent框架开发:从理论到实践的完整指南

月新闻

  • 2026年6月公司网站搭建最新热门渠道测评:四大低成本/零代码平台对比+避坑
  • 【Linux】Linux arm 编译QT程序,出现expected “}“报错
  • 【MATLAB例程】四基站二维AOA定位与距离辅助增强对比仿真。基于角度观测和测距修正的固定目标平面定位精度分析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号