项目地址
- 在线预览:frontend-ai-assistant-two.vercel.app/
- GitHub 源码:github.com/hewq/dify-c…
说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。
前言
前面几篇文章,我们已经完成了 AI 知识库问答应用的核心能力:
Dify RAG 知识库
Express BFF 代理
SSE 流式输出
Markdown 渲染
代码高亮
引用来源展示
从功能上看,它已经能回答问题了。
但从产品体验上看,它还比较像一个测试 Demo:
页面布局比较简单
输入框没有固定在底部
消息区不够像聊天产品
空状态不够友好
App.tsx 里 UI 代码较多
组件职责不够清晰
这一篇我们先不加新业务能力,而是做一次前端 UI 和组件结构升级。
目标是:
把项目从“功能能跑”升级成“看起来像一个正式 AI 产品”。
本篇目标
这一篇完成后,项目会具备:
1. 页面布局更像 ChatGPT / 企业知识库助手
2. 顶部有应用标题和说明
3. 输入区固定在底部
4. 消息区可滚动
5. 用户消息和 AI 消息样式区分明显
6. 空状态有示例问题
7. 引用来源展示更自然
8. App.tsx 不再堆满 UI 代码
同时我们会拆出这些组件:
ChatInput
ChatWindow
ChatMessage
SourceList
EmptyState
为什么要做组件拆分?
一开始写 Demo 时,把所有东西写在 App.tsx 里很正常。
比如:
状态定义
发送逻辑
流式更新
消息渲染
输入框
按钮
样式类名
都写在一个文件里,能快速跑通。
但项目功能一多,App.tsx 会迅速膨胀。
这会带来几个问题:
1. 文件越来越长,不容易阅读
2. UI 和业务逻辑混在一起
3. 后续改样式容易影响逻辑
4. 组件无法复用
5. 不利于继续扩展多会话、持久化、停止生成等能力
所以从这一篇开始,我们逐步把 UI 组件抽出去。
目标不是为了“过度设计”,而是让每个文件只负责一件事。
推荐目录结构
这一篇结束后,目录大概是:
src/
api/
difyStream.ts
components/
ChatInput.tsx
ChatWindow.tsx
ChatMessage.tsx
SourceList.tsx
EmptyState.tsx
types/
chat.ts
App.tsx
main.tsx
index.css
各文件职责:
App.tsx 负责状态和业务流程
ChatWindow 负责消息列表区域
ChatInput 负责底部输入区
ChatMessage 负责单条消息展示
SourceList 负责引用来源展示
EmptyState 负责无消息时的空状态
第一步:拆 ChatInput
先把底部输入框抽成组件。
新建:
src/components/ChatInput.tsx
写入:
type ChatInputProps = {
value: string
loading: boolean
onChange: (value: string) => void
onSend: () => void
onClear: () => void
}
export function ChatInput({
value,
loading,
onChange,
onSend,
onClear,
}: ChatInputProps) {
return (
<div className="chat-input-bar">
<textarea
value={value}
onChange={event => onChange(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
onSend()
}
}}
placeholder="输入你的问题,按 Enter 发送,Shift + Enter 换行"
rows={3}
disabled={loading}
/>
<div className="chat-input-actions">
<button onClick={onClear} disabled={loading}>
清空会话
</button>
<button onClick={onSend} disabled={loading || !value.trim()}>
{loading ? '回答中...' : '发送'}
</button>
</div>
</div>
)
}
这个组件不关心消息列表,也不关心 Dify。
它只负责:
输入内容
发送
清空
loading 状态
Enter 快捷键
第二步:拆 EmptyState
当没有任何消息时,直接显示空白页面体验不好。
我们加一个空状态,告诉用户这个助手能做什么。
新建:
src/components/EmptyState.tsx
写入:
export function EmptyState() {
const examples = [
'前端架构主要包括哪些内容?',
'什么是 RAG?',
'大型前端项目可以怎么分层?',
]
return (
<div className="empty-state">
<h2>Frontend AI Assistant</h2>
<p>基于你的知识库回答前端学习和架构问题。</p>
<div className="example-list">
{examples.map(example => (
<div className="example-card" key={example}>
{example}
</div>
))}
</div>
</div>
)
}
这一版示例问题只是展示。
后面我们会继续升级,让这些示例问题可以点击发送。
第三步:拆 ChatWindow
消息列表区域也应该独立出来。
新建:
src/components/ChatWindow.tsx
写入:
import { useEffect, useRef } from 'react'
import type { Message } from '../types/chat'
import { ChatMessage } from './ChatMessage'
import { EmptyState } from './EmptyState'
type ChatWindowProps = {
messages: Message[]
loading: boolean
}
export function ChatWindow({ messages, loading }: ChatWindowProps) {
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, loading])
if (messages.length === 0) {
return <EmptyState />
}
return (
<div className="chat-window">
{messages.map((message, index) => (
<ChatMessage
key={index}
role={message.role}
content={message.content}
sources={message.sources}
/>
))}
{loading && <div className="typing">AI 正在思考...</div>}
<div ref={bottomRef} />
</div>
)
}
这里加了一个自动滚动到底部的逻辑:
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
这样流式输出时,消息区会跟随最新内容滚动。
第四步:优化 ChatMessage
之前我们已经创建了 ChatMessage,现在把样式结构调整得更像聊天产品。
打开:
src/components/ChatMessage.tsx
调整为:
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import type { Source } from '../types/chat'
import { SourceList } from './SourceList'
type ChatMessageProps = {
role: 'user' | 'assistant'
content: string
sources?: Source[]
}
export function ChatMessage({ role, content, sources }: ChatMessageProps) {
const isUser = role === 'user'
return (
<div className={`message-row ${isUser ? 'message-row-user' : ''}`}>
<div className={`message-bubble ${isUser ? 'user' : 'assistant'}`}>
<div className="message-role">{isUser ? '你' : 'AI'}</div>
{isUser ? (
<div className="message-text">{content}</div>
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{content}
</ReactMarkdown>
</div>
)}
{!isUser && sources && sources.length > 0 && (
<SourceList sources={sources} />
)}
</div>
</div>
)
}
这里的结构是:
message-row 控制一整行
message-bubble 控制气泡
message-role 显示“你 / AI”
markdown-body 渲染 AI Markdown
SourceList 展示引用来源
第五步:整理 SourceList
如果你前一篇已经写过 SourceList,这里可以保持不变。
参考实现:
import type { Source } from '../types/chat'
type SourceListProps = {
sources: Source[]
}
export function SourceList({ sources }: SourceListProps) {
if (sources.length === 0) return null
return (
<div className="source-list">
<div className="source-title">引用来源</div>
<ul>
{sources.map((source, index) => (
<li key={index}>
<span>{source.documentName || '未知文档'}</span>
</li>
))}
</ul>
</div>
)
}
第六步:重构 App.tsx
现在 App.tsx 就可以只负责状态和业务逻辑。
示例结构:
import { useState } from 'react'
import { sendMessageToDifyStream } from './api/difyStream'
import { ChatInput } from './components/ChatInput'
import { ChatWindow } from './components/ChatWindow'
import type { Message } from './types/chat'
import './index.css'
function App() {
const [input, setInput] = useState('')
const [messages, setMessages] = useState<Message[]>([])
const [conversationId, setConversationId] = useState<string>()
const [loading, setLoading] = useState(false)
async function handleSend() {
const text = input.trim()
if (!text || loading) return
setInput('')
setLoading(true)
const assistantMessageIndex = messages.length + 1
setMessages(prev => [
...prev,
{ role: 'user', content: text },
{ role: 'assistant', content: '' },
])
try {
await sendMessageToDifyStream(text, conversationId, {
onMessage: chunk => {
setMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
content: current.content + chunk,
}
}
return next
})
},
onConversationId: id => {
setConversationId(id)
},
onSources: sources => {
setMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
sources: sources.map(source => ({
datasetName: source.dataset_name,
documentName: source.document_name,
content: source.content,
})),
}
}
return next
})
},
onError: error => {
console.error(error)
},
onDone: () => {
setLoading(false)
},
})
} catch (error) {
console.error(error)
setMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
content:
error instanceof Error
? `请求失败:${error.message}`
: '请求失败,请稍后重试。',
}
}
return next
})
setLoading(false)
}
}
function handleClear() {
setMessages([])
setConversationId(undefined)
}
return (
<div className="app-shell">
<header className="app-header">
<div>
<h1>Frontend AI Assistant</h1>
<p>基于 Dify + DeepSeek 的前端知识库助手</p>
</div>
</header>
<main className="app-main">
<ChatWindow messages={messages} loading={loading} />
</main>
<ChatInput
value={input}
loading={loading}
onChange={setInput}
onSend={handleSend}
onClear={handleClear}
/>
</div>
)
}
export default App
这时 App.tsx 依然有业务逻辑,但 UI 已经清爽很多。
后面我们还会把业务逻辑继续抽成自定义 Hook。
第七步:重写基础布局样式
把主要样式放到:
src/index.css
可以先写这一版:
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f4f6f8;
color: #1f2937;
font-family:
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
button,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled,
textarea:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
height: 72px;
padding: 12px 24px;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
}
.app-header h1 {
margin: 0;
font-size: 20px;
}
.app-header p {
margin: 4px 0 0;
color: #6b7280;
font-size: 13px;
}
.app-main {
flex: 1;
overflow: hidden;
display: flex;
justify-content: center;
}
.chat-window {
width: 100%;
max-width: 860px;
padding: 24px;
overflow-y: auto;
}
这部分先搭好整体结构:
顶部 header
中间滚动消息区
底部输入区
第八步:消息气泡样式
继续添加消息相关样式:
.message-row {
display: flex;
margin-bottom: 16px;
}
.message-row-user {
justify-content: flex-end;
}
.message-bubble {
max-width: 78%;
padding: 14px 16px;
border-radius: 14px;
line-height: 1.7;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.message-bubble.user {
background: #2563eb;
color: #ffffff;
border-bottom-right-radius: 4px;
}
.message-bubble.assistant {
background: #ffffff;
color: #1f2937;
border-bottom-left-radius: 4px;
}
.message-role {
font-size: 12px;
font-weight: 600;
opacity: 0.75;
margin-bottom: 6px;
}
.message-text {
white-space: pre-wrap;
}
用户消息靠右,AI 消息靠左。
这比之前统一堆在一列里更像聊天产品。
第九步:Markdown 和引用来源样式
补充 Markdown 样式:
.markdown-body p {
margin: 0 0 10px;
}
.markdown-body p:last-child {
margin-bottom: 0;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 22px;
margin: 8px 0;
}
.markdown-body pre {
padding: 12px;
overflow-x: auto;
border-radius: 8px;
}
.markdown-body code {
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
}
.source-list {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
font-size: 13px;
color: #4b5563;
}
.source-title {
font-weight: 600;
margin-bottom: 4px;
}
.source-list ul {
margin: 0;
padding-left: 18px;
}
第十步:空状态样式
空状态样式:
.empty-state {
width: 100%;
max-width: 720px;
margin: auto;
text-align: center;
padding: 24px;
}
.empty-state h2 {
font-size: 28px;
margin-bottom: 8px;
}
.empty-state p {
color: #6b7280;
margin-bottom: 24px;
}
.example-list {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.example-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 14px 16px;
text-align: left;
color: #374151;
}
空状态的作用不是花哨,而是降低用户第一次使用的心理成本。
用户看到示例问题,就知道可以问什么。
第十一步:底部输入区样式
输入区固定在底部:
.chat-input-bar {
background: #ffffff;
border-top: 1px solid #e5e7eb;
padding: 16px 24px;
}
.chat-input-bar textarea {
width: 100%;
max-width: 860px;
display: block;
margin: 0 auto;
resize: none;
border: 1px solid #d1d5db;
border-radius: 12px;
padding: 12px 14px;
outline: none;
}
.chat-input-bar textarea:focus {
border-color: #2563eb;
}
.chat-input-actions {
max-width: 860px;
margin: 10px auto 0;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.chat-input-actions button {
border: 0;
border-radius: 8px;
padding: 8px 14px;
background: #e5e7eb;
color: #111827;
}
.chat-input-actions button:last-child {
background: #2563eb;
color: #ffffff;
}
现在输入框不再跟着消息滚动,而是稳定在页面底部。
这对聊天产品非常重要。
第十二步:测试整体体验
启动项目:
npm run dev:all
测试以下场景:
1. 初始页面是否显示空状态
2. 输入问题后是否展示用户消息和 AI 消息
3. 用户消息是否靠右
4. AI 消息是否靠左
5. 输入区是否固定在底部
6. 消息多了以后中间区域是否滚动
7. 流式输出时是否自动滚到底部
8. Markdown 和引用来源是否仍然正常
如果这些都正常,说明这次 UI 和组件拆分成功。
这一篇没有做什么?
这一篇只做 UI 和组件结构,没有处理:
示例问题点击发送
会话本地持久化
多会话管理
停止生成
错误处理增强
自定义 Hook 重构
这些会在后面继续做。
做项目时很容易犯一个错误:一次改太多。
这一篇只处理“看起来像产品”和“组件更清晰”这两个目标。
当前版本的工程价值
这一步看起来只是改 UI,但对项目很重要。
它带来的价值是:
1. App.tsx 不再承担全部 UI
2. 消息区、输入区、单条消息职责分离
3. 后续加功能有明确位置
4. 页面体验更接近正式 AI 产品
5. 代码结构更适合继续扩展
比如后面要做“停止生成”,主要改 ChatInput。
要做“引用来源展开”,主要改 SourceList。
要做“点击示例问题发送”,主要改 EmptyState 和 ChatWindow。
组件边界清晰后,后续迭代会舒服很多。
本篇总结
这一篇我们没有新增 AI 能力,而是做了一次前端产品化升级。
完成了:
1. 拆分 ChatInput
2. 拆分 ChatWindow
3. 拆分 EmptyState
4. 优化 ChatMessage 结构
5. 复用 SourceList
6. 增加顶部 Header
7. 输入区固定底部
8. 消息区滚动
9. 用户 / AI 消息气泡区分
10. 空状态示例问题展示
现在项目已经不只是“能跑”,而是开始有了 AI 产品的样子。
下一篇我们继续提升可用性:
让示例问题可以点击发送,并用 localStorage 保存会话历史。