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