第 8 篇:从 Demo 到产品:拆分组件,优化聊天 UI

3 阅读9分钟

项目地址

说明:当前在线版本部署在 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

要做“点击示例问题发送”,主要改 EmptyStateChatWindow

组件边界清晰后,后续迭代会舒服很多。


本篇总结

这一篇我们没有新增 AI 能力,而是做了一次前端产品化升级。

完成了:

1. 拆分 ChatInput
2. 拆分 ChatWindow
3. 拆分 EmptyState
4. 优化 ChatMessage 结构
5. 复用 SourceList
6. 增加顶部 Header
7. 输入区固定底部
8. 消息区滚动
9. 用户 / AI 消息气泡区分
10. 空状态示例问题展示

现在项目已经不只是“能跑”,而是开始有了 AI 产品的样子。

下一篇我们继续提升可用性:

让示例问题可以点击发送,并用 localStorage 保存会话历史。